Baseline: wave 1A server complete, wave 1B Android scaffold + design system green
assembleDebug, testDebugUnitTest, and recordPaparazziDebug all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016bThmkmyUUdqQpy3MXFFe5
@@ -0,0 +1,14 @@
|
|||||||
|
# build outputs
|
||||||
|
app/build/
|
||||||
|
app/app/build/
|
||||||
|
app/.gradle/
|
||||||
|
app/.kotlin/
|
||||||
|
app/local.properties
|
||||||
|
# server runtime
|
||||||
|
server/pb_data/
|
||||||
|
server/pocketbase
|
||||||
|
server/.dev-credentials
|
||||||
|
# worker logs
|
||||||
|
logs/*.json
|
||||||
|
logs/*.err
|
||||||
|
logs/*.state
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle/
|
||||||
|
/local.properties
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
|
**/build/
|
||||||
|
.kotlin/
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2017 The Literata Project Authors (https://github.com/googlefonts/literata)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
// NOTE: no org.jetbrains.kotlin.android plugin — AGP 9's Kotlin support is
|
||||||
|
// built in. Only sub-plugins that add compiler extensions are applied here.
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.paparazzi)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "org.modg.bookshelf"
|
||||||
|
compileSdk = 37
|
||||||
|
buildToolsVersion = "37.0.0"
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "org.modg.bookshelf"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 37
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_21
|
||||||
|
targetCompatibility = JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
testOptions {
|
||||||
|
unitTests {
|
||||||
|
isIncludeAndroidResources = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paparazzi's Gradle plugin writes an HTML test report that trips on AGP 9's
|
||||||
|
// new DSL (cashapp/paparazzi#2111). Disable it; the PNG snapshots/report task
|
||||||
|
// output is what we actually care about here.
|
||||||
|
tasks.withType<Test>().configureEach {
|
||||||
|
reports.html.required.set(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
|
||||||
|
implementation(platform(libs.compose.bom))
|
||||||
|
implementation(libs.compose.ui)
|
||||||
|
implementation(libs.compose.ui.graphics)
|
||||||
|
implementation(libs.compose.ui.tooling.preview)
|
||||||
|
implementation(libs.compose.foundation)
|
||||||
|
implementation(libs.compose.material3)
|
||||||
|
implementation(libs.compose.material.icons.extended)
|
||||||
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
|
debugImplementation(libs.compose.ui.test.manifest)
|
||||||
|
|
||||||
|
implementation(libs.androidx.room.runtime)
|
||||||
|
implementation(libs.androidx.room.ktx)
|
||||||
|
ksp(libs.androidx.room.compiler)
|
||||||
|
|
||||||
|
implementation(libs.retrofit.core)
|
||||||
|
implementation(libs.retrofit.kotlinx.serialization.converter)
|
||||||
|
implementation(libs.okhttp.logging.interceptor)
|
||||||
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
|
|
||||||
|
implementation(libs.coil.compose)
|
||||||
|
implementation(libs.coil.network.okhttp)
|
||||||
|
|
||||||
|
implementation(libs.androidx.camera.core)
|
||||||
|
implementation(libs.androidx.camera.camera2)
|
||||||
|
implementation(libs.androidx.camera.lifecycle)
|
||||||
|
implementation(libs.androidx.camera.view)
|
||||||
|
implementation(libs.mlkit.barcode.scanning)
|
||||||
|
|
||||||
|
implementation(libs.androidx.work.runtime.ktx)
|
||||||
|
implementation(libs.androidx.datastore.preferences)
|
||||||
|
implementation(libs.accompanist.permissions)
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.robolectric)
|
||||||
|
testImplementation(libs.androidx.test.core)
|
||||||
|
testImplementation(libs.androidx.test.ext.junit)
|
||||||
|
testImplementation(libs.androidx.room.testing)
|
||||||
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
|
testImplementation(platform(libs.compose.bom))
|
||||||
|
testImplementation(libs.compose.ui.test.manifest)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# Release minification is currently disabled (isMinifyEnabled = false); this
|
||||||
|
# file is a placeholder for when wave-3+ enables it.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<!-- Camera for barcode scanning (ui.scan) -->
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.camera"
|
||||||
|
android:required="false" />
|
||||||
|
<uses-feature
|
||||||
|
android:name="android.hardware.camera.autofocus"
|
||||||
|
android:required="false" />
|
||||||
|
|
||||||
|
<!-- PocketBase sync, metadata lookup (Open Library / Google Books), cover uploads -->
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".BookshelfApplication"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Bookshelf"
|
||||||
|
tools:targetApi="31"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.Bookshelf">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package org.modg.bookshelf
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Application entry point. Per SPEC, DI is a hand-rolled [AppContainer] held
|
||||||
|
* here (no Hilt/kapt) — data.repo/data.local/data.remote wiring lands with
|
||||||
|
* the waves that introduce those packages. This scaffold wave intentionally
|
||||||
|
* leaves the container empty rather than stubbing out APIs that don't exist
|
||||||
|
* yet.
|
||||||
|
*/
|
||||||
|
class BookshelfApplication : Application()
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package org.modg.bookshelf
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.modg.bookshelf.ui.components.BookCover
|
||||||
|
import org.modg.bookshelf.ui.components.BookshelfScaffold
|
||||||
|
import org.modg.bookshelf.ui.components.EmptyState
|
||||||
|
import org.modg.bookshelf.ui.components.GoldDivider
|
||||||
|
import org.modg.bookshelf.ui.components.PaperSurface
|
||||||
|
import org.modg.bookshelf.ui.components.PrimaryButton
|
||||||
|
import org.modg.bookshelf.ui.components.SecondaryButton
|
||||||
|
import org.modg.bookshelf.ui.components.SyncStatus
|
||||||
|
import org.modg.bookshelf.ui.components.SyncStatusBar
|
||||||
|
import org.modg.bookshelf.ui.theme.BookshelfTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wave 2's placeholder home screen. There is no navigation graph yet (that's
|
||||||
|
* ui.nav.BookshelfNavHost, a later wave) — this Activity exists purely as a
|
||||||
|
* living style reference for the component set above, so wave 3 can see
|
||||||
|
* exactly how BookshelfScaffold/BookCover/etc. are meant to be used.
|
||||||
|
*/
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
BookshelfTheme {
|
||||||
|
StyleReferenceScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class SampleBook(val title: String, val coverUrl: String?)
|
||||||
|
|
||||||
|
private val sampleBooks = listOf(
|
||||||
|
SampleBook("The Left Hand of Darkness", "https://covers.openlibrary.org/b/isbn/9780441478125-L.jpg"),
|
||||||
|
SampleBook("A broken cover URL", "https://example.invalid/nope.jpg"),
|
||||||
|
SampleBook("No cover on file", null),
|
||||||
|
SampleBook("Gödel, Escher, Bach", null),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StyleReferenceScreen() {
|
||||||
|
var showEmptyState by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
BookshelfScaffold(
|
||||||
|
title = "Bookshelf",
|
||||||
|
actions = {
|
||||||
|
IconButton(onClick = { showEmptyState = !showEmptyState }) {
|
||||||
|
Icon(Icons.Outlined.Settings, contentDescription = "Toggle empty state")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
syncStatusBar = {
|
||||||
|
SyncStatusBar(status = SyncStatus.Synced, label = "Synced • just now")
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
PaperSurface(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
if (showEmptyState) {
|
||||||
|
EmptyState(
|
||||||
|
modifier = Modifier.padding(innerPadding),
|
||||||
|
title = "Your shelves are empty",
|
||||||
|
message = "Scan a barcode to add your first book.",
|
||||||
|
action = { PrimaryButton(text = "Scan a book", onClick = {}) },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Column(modifier = Modifier.padding(innerPadding)) {
|
||||||
|
SectionLabel("Covers")
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(3),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
items(sampleBooks) { book ->
|
||||||
|
BookCover(coverUrl = book.coverUrl, contentDescription = book.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GoldDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
SectionLabel("Buttons")
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
PrimaryButton(text = "Save", onClick = {})
|
||||||
|
SecondaryButton(text = "Skip", onClick = {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SectionLabel(text: String) {
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, top = 16.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package org.modg.bookshelf.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.AutoStories
|
||||||
|
import androidx.compose.material.icons.outlined.BrokenImage
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.AsyncImagePainter
|
||||||
|
import coil3.compose.SubcomposeAsyncImage
|
||||||
|
import coil3.compose.SubcomposeAsyncImageContent
|
||||||
|
|
||||||
|
/** The canonical book-cover aspect ratio (standard trade paperback proportions). */
|
||||||
|
const val BookCoverAspectRatio = 2f / 3f
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A book cover image, always drawn at [BookCoverAspectRatio]. Covers are the
|
||||||
|
* hero of this app's design — real art fills the whole shape edge to edge.
|
||||||
|
* When there's no [coverUrl], or the load fails, we fall back to the same
|
||||||
|
* restrained "letterpress" placeholder: a paper-toned panel with a debossed
|
||||||
|
* spine motif rather than a broken-image icon or empty grey box.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BookCover(
|
||||||
|
coverUrl: String?,
|
||||||
|
contentDescription: String?,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
shape: androidx.compose.ui.graphics.Shape = MaterialTheme.shapes.small,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.aspectRatio(BookCoverAspectRatio)
|
||||||
|
.clip(shape),
|
||||||
|
) {
|
||||||
|
if (coverUrl.isNullOrBlank()) {
|
||||||
|
CoverPlaceholder(errored = false)
|
||||||
|
} else {
|
||||||
|
SubcomposeAsyncImage(
|
||||||
|
model = coverUrl,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
) {
|
||||||
|
when (painter.state) {
|
||||||
|
is AsyncImagePainter.State.Error -> CoverPlaceholder(errored = true)
|
||||||
|
is AsyncImagePainter.State.Loading,
|
||||||
|
is AsyncImagePainter.State.Empty,
|
||||||
|
-> CoverPlaceholder(errored = false, loading = true)
|
||||||
|
is AsyncImagePainter.State.Success -> SubcomposeAsyncImageContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CoverPlaceholder(errored: Boolean, loading: Boolean = false) {
|
||||||
|
val paperAlt = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
val ink = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(paperAlt)
|
||||||
|
.border(width = 1.dp, color = ink.copy(alpha = 0.15f))
|
||||||
|
.padding(2.dp)
|
||||||
|
.border(width = 1.dp, color = ink.copy(alpha = 0.1f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (!loading) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (errored) Icons.Outlined.BrokenImage else Icons.Outlined.AutoStories,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = ink.copy(alpha = if (errored) 0.35f else 0.28f),
|
||||||
|
modifier = Modifier.fillMaxSize(0.32f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package org.modg.bookshelf.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shell every Bookshelf screen is built on: a top bar with the serif
|
||||||
|
* screen title and a gold hairline rule underneath, plus optional nav/action
|
||||||
|
* slots, FAB, and a bottom [SyncStatusBar] slot. Screens should reach for
|
||||||
|
* this instead of a bare [Scaffold] so the chrome stays consistent.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun BookshelfScaffold(
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
navigationIcon: @Composable () -> Unit = {},
|
||||||
|
actions: @Composable () -> Unit = {},
|
||||||
|
floatingActionButton: @Composable () -> Unit = {},
|
||||||
|
syncStatusBar: @Composable () -> Unit = {},
|
||||||
|
content: @Composable (PaddingValues) -> Unit,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
|
topBar = {
|
||||||
|
Column {
|
||||||
|
CenterAlignedTopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(text = title, style = MaterialTheme.typography.titleLarge)
|
||||||
|
},
|
||||||
|
navigationIcon = navigationIcon,
|
||||||
|
actions = { actions() },
|
||||||
|
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
GoldDivider()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
floatingActionButton = floatingActionButton,
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
content = { innerPadding ->
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
content(innerPadding)
|
||||||
|
}
|
||||||
|
syncStatusBar()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package org.modg.bookshelf.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
private val ButtonShape = RoundedCornerShape(6.dp)
|
||||||
|
private val ButtonPadding = PaddingValues(horizontal = 24.dp, vertical = 12.dp)
|
||||||
|
|
||||||
|
/** The mahogany-filled call to action button — Save, Add to shelf, Sign in, etc. */
|
||||||
|
@Composable
|
||||||
|
fun PrimaryButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = ButtonShape,
|
||||||
|
contentPadding = ButtonPadding,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(text = text, style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The quieter outlined companion — Cancel, Skip, secondary actions. */
|
||||||
|
@Composable
|
||||||
|
fun SecondaryButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = modifier,
|
||||||
|
enabled = enabled,
|
||||||
|
shape = ButtonShape,
|
||||||
|
contentPadding = ButtonPadding,
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(
|
||||||
|
contentColor = MaterialTheme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||||
|
) {
|
||||||
|
Text(text = text, style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package org.modg.bookshelf.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.AutoStories
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A quiet, on-brand empty state — an icon, a serif headline, a supporting
|
||||||
|
* line, and an optional call to action (e.g. library's "invite the first
|
||||||
|
* scan"). Never just blank space.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EmptyState(
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
message: String? = null,
|
||||||
|
action: @Composable (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.AutoStories,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.secondary,
|
||||||
|
modifier = Modifier.padding(bottom = 16.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
if (message != null) {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (action != null) {
|
||||||
|
Column(modifier = Modifier.padding(top = 24.dp)) {
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package org.modg.bookshelf.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The thin gold hairline rule used under topbars and between sections — one
|
||||||
|
* of the app's few recurring "motifs" per the SPEC. Fades to transparent at
|
||||||
|
* both ends rather than terminating abruptly, so it reads as a rule on the
|
||||||
|
* page instead of a UI divider.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun GoldDivider(modifier: Modifier = Modifier) {
|
||||||
|
val gold = MaterialTheme.colorScheme.secondary
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(1.dp)
|
||||||
|
.drawBehind {
|
||||||
|
val brush = Brush.horizontalGradient(
|
||||||
|
colors = listOf(
|
||||||
|
gold.copy(alpha = 0f),
|
||||||
|
gold.copy(alpha = 0.8f),
|
||||||
|
gold.copy(alpha = 0f),
|
||||||
|
),
|
||||||
|
startX = 0f,
|
||||||
|
endX = size.width,
|
||||||
|
)
|
||||||
|
drawLine(
|
||||||
|
brush = brush,
|
||||||
|
start = Offset(0f, size.height / 2f),
|
||||||
|
end = Offset(size.width, size.height / 2f),
|
||||||
|
strokeWidth = size.height,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package org.modg.bookshelf.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base surface for large areas of the app (screen backgrounds, cards).
|
||||||
|
* Paints [MaterialTheme]'s surface color plus a very faint, deterministic
|
||||||
|
* paper-grain speckle so big flat areas read as paper rather than a plain
|
||||||
|
* digital fill. The grain is intentionally subtle — this is restrained, not
|
||||||
|
* skeuomorphic parchment.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PaperSurface(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
grain: Boolean = true,
|
||||||
|
contentPadding: Dp = 0.dp,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.then(if (grain) Modifier.paperGrain() else Modifier)
|
||||||
|
.padding(contentPadding),
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deterministic speckle grain, cheap enough to redraw every frame. */
|
||||||
|
private fun Modifier.paperGrain(): Modifier = drawWithCache {
|
||||||
|
val random = Random(seed = 42)
|
||||||
|
val speckleCount = ((size.width * size.height) / 9000f).toInt().coerceIn(24, 900)
|
||||||
|
val speckles = List(speckleCount) {
|
||||||
|
Offset(random.nextFloat() * size.width, random.nextFloat() * size.height) to
|
||||||
|
(0.35f + random.nextFloat() * 0.5f)
|
||||||
|
}
|
||||||
|
onDrawWithContent {
|
||||||
|
drawContent()
|
||||||
|
drawIntoCanvas {
|
||||||
|
for ((offset, radiusScale) in speckles) {
|
||||||
|
drawCircle(
|
||||||
|
color = Color.Black.copy(alpha = 0.018f * radiusScale),
|
||||||
|
radius = 1.1f * radiusScale,
|
||||||
|
center = offset,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package org.modg.bookshelf.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.LinearEasing
|
||||||
|
import androidx.compose.animation.core.RepeatMode
|
||||||
|
import androidx.compose.animation.core.animateFloat
|
||||||
|
import androidx.compose.animation.core.infiniteRepeatable
|
||||||
|
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Presentation-only sync state for [SyncStatusBar]. The real sync engine
|
||||||
|
* (data.repo.SyncEngine) maps its richer state into this — never the other
|
||||||
|
* way around. Per SPEC: sync failure is a quiet status line, never a dialog.
|
||||||
|
*/
|
||||||
|
enum class SyncStatus {
|
||||||
|
Synced,
|
||||||
|
Syncing,
|
||||||
|
Offline,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A slim, quiet status line — never a blocking banner or dialog. Sits at the
|
||||||
|
* bottom of [BookshelfScaffold] screens.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SyncStatusBar(
|
||||||
|
status: SyncStatus,
|
||||||
|
label: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
StatusDot(status)
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatusDot(status: SyncStatus) {
|
||||||
|
val color = when (status) {
|
||||||
|
SyncStatus.Synced -> MaterialTheme.colorScheme.secondary
|
||||||
|
SyncStatus.Syncing -> MaterialTheme.colorScheme.secondary
|
||||||
|
SyncStatus.Offline -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
SyncStatus.Error -> MaterialTheme.colorScheme.error
|
||||||
|
}
|
||||||
|
if (status == SyncStatus.Syncing) {
|
||||||
|
val transition = rememberInfiniteTransition(label = "sync-pulse")
|
||||||
|
val alpha by transition.animateFloat(
|
||||||
|
initialValue = 0.3f,
|
||||||
|
targetValue = 1f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(700, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Reverse,
|
||||||
|
),
|
||||||
|
label = "sync-pulse-alpha",
|
||||||
|
)
|
||||||
|
Dot(color = color, modifier = Modifier.graphicsLayer { this.alpha = alpha })
|
||||||
|
} else {
|
||||||
|
Dot(color = color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Dot(color: Color, modifier: Modifier = Modifier) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(color),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package org.modg.bookshelf.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
// Palette straight from docs/SPEC.md — "feels like books": warm paper, dark
|
||||||
|
// mahogany, gold + silver metallics. Do not add colors outside this family;
|
||||||
|
// if a new role is needed, derive it from these rather than inventing a hue.
|
||||||
|
|
||||||
|
// ---- Light ----
|
||||||
|
val PaperLight = Color(0xFFF5EDE0)
|
||||||
|
val PaperAltLight = Color(0xFFEDE3D2)
|
||||||
|
val InkLight = Color(0xFF2B211A)
|
||||||
|
val InkSoftLight = Color(0xFF5A4A3D)
|
||||||
|
val MahoganyLight = Color(0xFF5C2E23)
|
||||||
|
val MahoganyDeepLight = Color(0xFF3E1E17)
|
||||||
|
val GoldLight = Color(0xFFC0932F)
|
||||||
|
val GoldSoftLight = Color(0xFFD9B45B)
|
||||||
|
val SilverLight = Color(0xFF9CA3AF)
|
||||||
|
val SilverSoftLight = Color(0xFFC7CCD1)
|
||||||
|
val ErrorLight = Color(0xFF8C3B2E)
|
||||||
|
val ErrorContainerLight = Color(0xFFF2D9D1)
|
||||||
|
val OnErrorContainerLight = Color(0xFF4A190F)
|
||||||
|
|
||||||
|
// Extra surface tones derived from PaperLight/PaperAltLight for the M3
|
||||||
|
// surface-container ladder (introduced after the base M3 ColorScheme; these
|
||||||
|
// keep every elevation looking like a shade of paper, never grey).
|
||||||
|
val SurfaceDimLight = Color(0xFFE4D8C5)
|
||||||
|
val SurfaceBrightLight = Color(0xFFFBF6EC)
|
||||||
|
val SurfaceContainerLowestLight = Color(0xFFFFFFFF)
|
||||||
|
val SurfaceContainerLowLight = Color(0xFFF1E7D7)
|
||||||
|
val SurfaceContainerLight = Color(0xFFEDE3D2)
|
||||||
|
val SurfaceContainerHighLight = Color(0xFFE7DAC5)
|
||||||
|
val SurfaceContainerHighestLight = Color(0xFFE0D0B7)
|
||||||
|
|
||||||
|
// ---- Dark ----
|
||||||
|
val GroundDark = Color(0xFF1C1411)
|
||||||
|
val SurfaceDark = Color(0xFF241A15)
|
||||||
|
val PaperTextDark = Color(0xFFE8DCC8)
|
||||||
|
val MahoganyDark = Color(0xFF7A3E2F)
|
||||||
|
val MahoganyDeepDark = Color(0xFF3E1E17)
|
||||||
|
val GoldDark = Color(0xFFD9B45B)
|
||||||
|
val SilverDark = Color(0xFFC7CCD1)
|
||||||
|
val OnSurfaceVariantDark = Color(0xFFC7B9A3)
|
||||||
|
val SurfaceVariantDark = Color(0xFF2E2119)
|
||||||
|
val OutlineDark = Color(0xFF6B6F74)
|
||||||
|
val OutlineVariantDark = Color(0xFF3A3D40)
|
||||||
|
val ErrorDark = Color(0xFFC86A57)
|
||||||
|
val ErrorContainerDark = Color(0xFF4A2620)
|
||||||
|
val OnErrorContainerDark = Color(0xFFF2D9D1)
|
||||||
|
|
||||||
|
val SurfaceDimDark = Color(0xFF1C1411)
|
||||||
|
val SurfaceBrightDark = Color(0xFF3A2C22)
|
||||||
|
val SurfaceContainerLowestDark = Color(0xFF140D0B)
|
||||||
|
val SurfaceContainerLowDark = Color(0xFF211712)
|
||||||
|
val SurfaceContainerDark = Color(0xFF271C16)
|
||||||
|
val SurfaceContainerHighDark = Color(0xFF32241C)
|
||||||
|
val SurfaceContainerHighestDark = Color(0xFF3D2C22)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package org.modg.bookshelf.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.lightColorScheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
private val LightColors = lightColorScheme(
|
||||||
|
primary = MahoganyLight,
|
||||||
|
onPrimary = PaperLight,
|
||||||
|
primaryContainer = MahoganyDeepLight,
|
||||||
|
onPrimaryContainer = PaperLight,
|
||||||
|
secondary = GoldLight,
|
||||||
|
onSecondary = InkLight,
|
||||||
|
secondaryContainer = GoldSoftLight,
|
||||||
|
onSecondaryContainer = MahoganyDeepLight,
|
||||||
|
tertiary = SilverLight,
|
||||||
|
onTertiary = InkLight,
|
||||||
|
tertiaryContainer = SilverSoftLight,
|
||||||
|
onTertiaryContainer = InkLight,
|
||||||
|
error = ErrorLight,
|
||||||
|
onError = PaperLight,
|
||||||
|
errorContainer = ErrorContainerLight,
|
||||||
|
onErrorContainer = OnErrorContainerLight,
|
||||||
|
background = PaperLight,
|
||||||
|
onBackground = InkLight,
|
||||||
|
surface = PaperLight,
|
||||||
|
onSurface = InkLight,
|
||||||
|
surfaceVariant = PaperAltLight,
|
||||||
|
onSurfaceVariant = InkSoftLight,
|
||||||
|
outline = SilverLight,
|
||||||
|
outlineVariant = SilverSoftLight,
|
||||||
|
scrim = InkLight,
|
||||||
|
inverseSurface = InkLight,
|
||||||
|
inverseOnSurface = PaperLight,
|
||||||
|
inversePrimary = GoldSoftLight,
|
||||||
|
surfaceTint = MahoganyLight,
|
||||||
|
surfaceDim = SurfaceDimLight,
|
||||||
|
surfaceBright = SurfaceBrightLight,
|
||||||
|
surfaceContainerLowest = SurfaceContainerLowestLight,
|
||||||
|
surfaceContainerLow = SurfaceContainerLowLight,
|
||||||
|
surfaceContainer = SurfaceContainerLight,
|
||||||
|
surfaceContainerHigh = SurfaceContainerHighLight,
|
||||||
|
surfaceContainerHighest = SurfaceContainerHighestLight,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val DarkColors = darkColorScheme(
|
||||||
|
primary = MahoganyDark,
|
||||||
|
onPrimary = PaperTextDark,
|
||||||
|
primaryContainer = MahoganyDeepDark,
|
||||||
|
onPrimaryContainer = PaperTextDark,
|
||||||
|
secondary = GoldDark,
|
||||||
|
onSecondary = GroundDark,
|
||||||
|
secondaryContainer = Color(0xFF4A3820),
|
||||||
|
onSecondaryContainer = GoldDark,
|
||||||
|
tertiary = SilverDark,
|
||||||
|
onTertiary = GroundDark,
|
||||||
|
tertiaryContainer = Color(0xFF3A3F45),
|
||||||
|
onTertiaryContainer = SilverDark,
|
||||||
|
error = ErrorDark,
|
||||||
|
onError = GroundDark,
|
||||||
|
errorContainer = ErrorContainerDark,
|
||||||
|
onErrorContainer = OnErrorContainerDark,
|
||||||
|
background = GroundDark,
|
||||||
|
onBackground = PaperTextDark,
|
||||||
|
surface = SurfaceDark,
|
||||||
|
onSurface = PaperTextDark,
|
||||||
|
surfaceVariant = SurfaceVariantDark,
|
||||||
|
onSurfaceVariant = OnSurfaceVariantDark,
|
||||||
|
outline = OutlineDark,
|
||||||
|
outlineVariant = OutlineVariantDark,
|
||||||
|
scrim = GroundDark,
|
||||||
|
inverseSurface = PaperLight,
|
||||||
|
inverseOnSurface = InkLight,
|
||||||
|
inversePrimary = MahoganyLight,
|
||||||
|
surfaceTint = MahoganyDark,
|
||||||
|
surfaceDim = SurfaceDimDark,
|
||||||
|
surfaceBright = SurfaceBrightDark,
|
||||||
|
surfaceContainerLowest = SurfaceContainerLowestDark,
|
||||||
|
surfaceContainerLow = SurfaceContainerLowDark,
|
||||||
|
surfaceContainer = SurfaceContainerDark,
|
||||||
|
surfaceContainerHigh = SurfaceContainerHighDark,
|
||||||
|
surfaceContainerHighest = SurfaceContainerHighestDark,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bookshelf's Material3 theme. Dynamic color is intentionally never wired up
|
||||||
|
* here — a wallpaper-derived palette would fight the warm-paper-and-mahogany
|
||||||
|
* identity the SPEC calls for, so there is no dynamicColor parameter at all.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BookshelfTheme(
|
||||||
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val colorScheme = if (darkTheme) DarkColors else LightColors
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
typography = BookshelfTypography,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package org.modg.bookshelf.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.material3.Typography
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.Font
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.modg.bookshelf.R
|
||||||
|
|
||||||
|
// Literata (OFL, bundled in res/font — see LICENSE-Literata-OFL.txt) is the
|
||||||
|
// serif voice for anything that reads as a title or heading: it's what makes
|
||||||
|
// this app feel like a shelf of books rather than a generic list app. Body
|
||||||
|
// and UI copy stay on the system sans so long paragraphs (descriptions,
|
||||||
|
// notes) stay easy to read at small sizes.
|
||||||
|
val Literata = FontFamily(
|
||||||
|
Font(R.font.literata_regular, FontWeight.Normal),
|
||||||
|
Font(R.font.literata_italic, FontWeight.Normal, FontStyle.Italic),
|
||||||
|
Font(R.font.literata_medium, FontWeight.Medium),
|
||||||
|
Font(R.font.literata_medium_italic, FontWeight.Medium, FontStyle.Italic),
|
||||||
|
Font(R.font.literata_semibold, FontWeight.SemiBold),
|
||||||
|
Font(R.font.literata_semibold_italic, FontWeight.SemiBold, FontStyle.Italic),
|
||||||
|
Font(R.font.literata_bold, FontWeight.Bold),
|
||||||
|
Font(R.font.literata_bold_italic, FontWeight.Bold, FontStyle.Italic),
|
||||||
|
)
|
||||||
|
|
||||||
|
val SystemSans = FontFamily.Default
|
||||||
|
|
||||||
|
// Generous line-height throughout per SPEC ("Generous line-height").
|
||||||
|
val BookshelfTypography = Typography(
|
||||||
|
displayLarge = TextStyle(
|
||||||
|
fontFamily = Literata,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 57.sp,
|
||||||
|
lineHeight = 68.sp,
|
||||||
|
letterSpacing = (-0.25).sp,
|
||||||
|
),
|
||||||
|
displayMedium = TextStyle(
|
||||||
|
fontFamily = Literata,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 45.sp,
|
||||||
|
lineHeight = 56.sp,
|
||||||
|
),
|
||||||
|
displaySmall = TextStyle(
|
||||||
|
fontFamily = Literata,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 36.sp,
|
||||||
|
lineHeight = 46.sp,
|
||||||
|
),
|
||||||
|
headlineLarge = TextStyle(
|
||||||
|
fontFamily = Literata,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 32.sp,
|
||||||
|
lineHeight = 42.sp,
|
||||||
|
),
|
||||||
|
headlineMedium = TextStyle(
|
||||||
|
fontFamily = Literata,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 28.sp,
|
||||||
|
lineHeight = 38.sp,
|
||||||
|
),
|
||||||
|
headlineSmall = TextStyle(
|
||||||
|
fontFamily = Literata,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 24.sp,
|
||||||
|
lineHeight = 34.sp,
|
||||||
|
),
|
||||||
|
titleLarge = TextStyle(
|
||||||
|
// Topbar / screen titles — the "serif title" the scaffold calls for.
|
||||||
|
fontFamily = Literata,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 22.sp,
|
||||||
|
lineHeight = 30.sp,
|
||||||
|
),
|
||||||
|
titleMedium = TextStyle(
|
||||||
|
fontFamily = Literata,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
lineHeight = 26.sp,
|
||||||
|
letterSpacing = 0.15.sp,
|
||||||
|
),
|
||||||
|
titleSmall = TextStyle(
|
||||||
|
fontFamily = Literata,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 24.sp,
|
||||||
|
letterSpacing = 0.1.sp,
|
||||||
|
),
|
||||||
|
bodyLarge = TextStyle(
|
||||||
|
fontFamily = SystemSans,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 26.sp,
|
||||||
|
letterSpacing = 0.5.sp,
|
||||||
|
),
|
||||||
|
bodyMedium = TextStyle(
|
||||||
|
fontFamily = SystemSans,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 22.sp,
|
||||||
|
letterSpacing = 0.25.sp,
|
||||||
|
),
|
||||||
|
bodySmall = TextStyle(
|
||||||
|
fontFamily = SystemSans,
|
||||||
|
fontWeight = FontWeight.Normal,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 18.sp,
|
||||||
|
letterSpacing = 0.4.sp,
|
||||||
|
),
|
||||||
|
labelLarge = TextStyle(
|
||||||
|
fontFamily = SystemSans,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
lineHeight = 20.sp,
|
||||||
|
letterSpacing = 0.1.sp,
|
||||||
|
),
|
||||||
|
labelMedium = TextStyle(
|
||||||
|
fontFamily = SystemSans,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
lineHeight = 18.sp,
|
||||||
|
letterSpacing = 0.5.sp,
|
||||||
|
),
|
||||||
|
labelSmall = TextStyle(
|
||||||
|
fontFamily = SystemSans,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
lineHeight = 16.sp,
|
||||||
|
letterSpacing = 0.5.sp,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#5C2E23"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<!-- Three book spines, gold on mahogany, standing side by side. -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#D9B45B"
|
||||||
|
android:pathData="M34,28 h10 v52 h-10 z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#C0932F"
|
||||||
|
android:pathData="M48,24 h10 v56 h-10 z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#D9B45B"
|
||||||
|
android:pathData="M62,30 h10 v50 h-10 z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#E8DCC8"
|
||||||
|
android:pathData="M30,80 h48 v6 h-48 z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.Bookshelf" parent="android:Theme.Material.NoActionBar">
|
||||||
|
<item name="android:windowBackground">@color/ground_dark</item>
|
||||||
|
<item name="android:statusBarColor">@color/ground_dark</item>
|
||||||
|
<item name="android:windowLightStatusBar">false</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Mirrors ui.theme.Color.kt; used only for the pre-Compose XML window theme. -->
|
||||||
|
<color name="paper_light">#F5EDE0</color>
|
||||||
|
<color name="ground_dark">#1C1411</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Bookshelf</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Base application theme. The real Material3 theming happens in Compose
|
||||||
|
(ui.theme.BookshelfTheme); this XML theme only needs to cover the
|
||||||
|
window background shown before the first Compose frame. -->
|
||||||
|
<style name="Theme.Bookshelf" parent="android:Theme.Material.Light.NoActionBar">
|
||||||
|
<item name="android:windowBackground">@color/paper_light</item>
|
||||||
|
<item name="android:statusBarColor">@color/paper_light</item>
|
||||||
|
<item name="android:windowLightStatusBar">true</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package org.modg.bookshelf.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import app.cash.paparazzi.DeviceConfig
|
||||||
|
import app.cash.paparazzi.Paparazzi
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.modg.bookshelf.ui.theme.BookshelfTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the wave-2 component set to PNG on the JVM (no device/emulator).
|
||||||
|
* Every component gets a light and a dark snapshot so both themes required
|
||||||
|
* by the SPEC are actually exercised, not just assumed to work.
|
||||||
|
*
|
||||||
|
* Run `./gradlew recordPaparazziDebug` to (re)record golden PNGs under
|
||||||
|
* src/test/snapshots, or `./gradlew verifyPaparazziDebug` to diff against them.
|
||||||
|
*/
|
||||||
|
class ComponentGalleryPaparazziTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_6)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun scaffoldAndDividerLight() = snapshotBothThemes("scaffold") {
|
||||||
|
BookshelfScaffold(
|
||||||
|
title = "Bookshelf",
|
||||||
|
syncStatusBar = { SyncStatusBar(status = SyncStatus.Synced, label = "Synced") },
|
||||||
|
) { padding ->
|
||||||
|
PaperSurface(modifier = Modifier.fillMaxWidth().padding(padding)) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text("A page of paper, with a gold rule below the title bar.")
|
||||||
|
GoldDivider(modifier = Modifier.padding(vertical = 16.dp))
|
||||||
|
Text("Restrained, not skeuomorphic.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the "no coverUrl" path is snapshotted here: it's the one
|
||||||
|
// deterministic BookCover state (no network I/O). The loading/error
|
||||||
|
// states are real code paths (see BookCover.kt's CoverPlaceholder /
|
||||||
|
// AsyncImagePainter.State.Error branch) but depend on an async Coil
|
||||||
|
// network load settling inside a single synchronous Paparazzi frame,
|
||||||
|
// which isn't reliable on a sandboxed JVM test — see BUILD_NOTES.md.
|
||||||
|
@Test
|
||||||
|
fun bookCoverPlaceholder() = snapshotBothThemes("book-cover") {
|
||||||
|
androidx.compose.foundation.layout.Row(
|
||||||
|
modifier = Modifier.padding(24.dp).fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
BookCover(
|
||||||
|
coverUrl = null,
|
||||||
|
contentDescription = "No cover on file",
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
BookCover(
|
||||||
|
coverUrl = null,
|
||||||
|
contentDescription = "Another shelf slot",
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buttons() = snapshotBothThemes("buttons") {
|
||||||
|
Column(modifier = Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
PrimaryButton(text = "Save", onClick = {})
|
||||||
|
SecondaryButton(text = "Skip", onClick = {})
|
||||||
|
PrimaryButton(text = "Disabled", onClick = {}, enabled = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun emptyState() = snapshotBothThemes("empty-state") {
|
||||||
|
EmptyState(
|
||||||
|
title = "Your shelves are empty",
|
||||||
|
message = "Scan a barcode to add your first book.",
|
||||||
|
action = { PrimaryButton(text = "Scan a book", onClick = {}) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun syncStatusBarStates() = snapshotBothThemes("sync-status-bar") {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||||
|
SyncStatusBar(status = SyncStatus.Synced, label = "Synced • 2m ago")
|
||||||
|
SyncStatusBar(status = SyncStatus.Syncing, label = "Syncing…")
|
||||||
|
SyncStatusBar(status = SyncStatus.Offline, label = "Offline — will sync later")
|
||||||
|
SyncStatusBar(status = SyncStatus.Error, label = "Couldn't reach server")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshots [content] once under the light theme and once under dark. */
|
||||||
|
private fun snapshotBothThemes(
|
||||||
|
name: String,
|
||||||
|
content: @androidx.compose.runtime.Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
paparazzi.snapshot(name = "$name-light") {
|
||||||
|
BookshelfTheme(darkTheme = false) {
|
||||||
|
PaperSurface { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paparazzi.snapshot(name = "$name-dark") {
|
||||||
|
BookshelfTheme(darkTheme = true) {
|
||||||
|
PaperSurface { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
//
|
||||||
|
// AGP 9 has Kotlin support BUILT IN (the org.jetbrains.kotlin.android plugin no
|
||||||
|
// longer exists / must not be applied). AGP's built-in Kotlin defaults to bundling
|
||||||
|
// its own Kotlin Gradle Plugin version; bump it here to the version we actually
|
||||||
|
// want (must match the Compose/serialization plugin versions below).
|
||||||
|
buildscript {
|
||||||
|
dependencies {
|
||||||
|
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.3.21")
|
||||||
|
classpath("com.google.devtools.ksp:symbol-processing-gradle-plugin:2.3.11")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
alias(libs.plugins.ksp) apply false
|
||||||
|
alias(libs.plugins.paparazzi) apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Project-wide Gradle settings.
|
||||||
|
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.caching=true
|
||||||
|
|
||||||
|
# AndroidX
|
||||||
|
android.useAndroidX=true
|
||||||
|
|
||||||
|
# Kotlin
|
||||||
|
kotlin.code.style=official
|
||||||
|
|
||||||
|
# Compose (dynamic color intentionally off, see ui.theme)
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "9.1.1"
|
||||||
|
kotlin = "2.3.21"
|
||||||
|
ksp = "2.3.11"
|
||||||
|
composeBom = "2026.08.00"
|
||||||
|
coreKtx = "1.19.0"
|
||||||
|
activityCompose = "1.13.0"
|
||||||
|
lifecycle = "2.11.0"
|
||||||
|
navigationCompose = "2.10.0"
|
||||||
|
room = "2.8.4"
|
||||||
|
retrofit = "3.0.0"
|
||||||
|
retrofitKotlinxSerializationConverter = "1.0.0"
|
||||||
|
okhttp = "5.5.0"
|
||||||
|
kotlinxSerializationJson = "1.11.0"
|
||||||
|
kotlinxCoroutines = "1.11.0"
|
||||||
|
coil = "3.6.2"
|
||||||
|
cameraX = "1.6.2"
|
||||||
|
mlkitBarcodeScanning = "17.3.0"
|
||||||
|
workManager = "2.11.2"
|
||||||
|
datastorePreferences = "1.2.1"
|
||||||
|
accompanistPermissions = "0.37.3"
|
||||||
|
paparazzi = "2.0.0-alpha05"
|
||||||
|
junit = "4.13.2"
|
||||||
|
robolectric = "4.16.1"
|
||||||
|
androidxTestCore = "1.7.0"
|
||||||
|
androidxTestExtJunit = "1.3.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||||
|
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
|
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
|
||||||
|
|
||||||
|
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
|
compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
||||||
|
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
|
||||||
|
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||||
|
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||||
|
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||||
|
androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
|
||||||
|
|
||||||
|
retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
|
||||||
|
retrofit-kotlinx-serialization-converter = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationConverter" }
|
||||||
|
okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
|
||||||
|
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
|
||||||
|
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" }
|
||||||
|
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" }
|
||||||
|
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
|
||||||
|
|
||||||
|
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
||||||
|
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
|
||||||
|
|
||||||
|
androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraX" }
|
||||||
|
androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraX" }
|
||||||
|
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraX" }
|
||||||
|
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraX" }
|
||||||
|
|
||||||
|
mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcodeScanning" }
|
||||||
|
|
||||||
|
androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workManager" }
|
||||||
|
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePreferences" }
|
||||||
|
accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanistPermissions" }
|
||||||
|
|
||||||
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }
|
||||||
|
androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" }
|
||||||
|
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExtJunit" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
|
paparazzi = { id = "app.cash.paparazzi", version.ref = "paparazzi" }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$( uname )" in #(
|
||||||
|
CYGWIN* ) cygwin=true ;; #(
|
||||||
|
Darwin* ) darwin=true ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||||
|
else
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "Bookshelf"
|
||||||
|
include(":app")
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Bookshelf — session handoff
|
||||||
|
Written 2026-09-06 by the Opus orchestrator, after a sprite restart forced a fresh session.
|
||||||
|
|
||||||
|
## Read these first, in order
|
||||||
|
1. `docs/SPEC.md` — the authoritative product/technical contract. Unchanged and still correct.
|
||||||
|
Every worker prompt must point at it. Do not restate it; do not let it drift.
|
||||||
|
2. This file — operational state, what's done, what bit us, what's next.
|
||||||
|
|
||||||
|
## Operating model (the user explicitly asked for this — keep it)
|
||||||
|
The user is on the **$20/mo Pro plan** and wants Opus used sparingly.
|
||||||
|
- **Opus = orchestrator only.** Write specs, launch workers, verify results, decide.
|
||||||
|
Do NOT write app code yourself. Do NOT read large files into Opus context.
|
||||||
|
- **Sonnet = all implementation**, via `claude -p` (NOT the Agent tool — the user asked
|
||||||
|
for `claude -p` specifically, and it keeps worker output out of the orchestrator's context).
|
||||||
|
- Read worker output via `logs/<name>.summary` / `jq -r '.result'`, never by cat-ing source.
|
||||||
|
- Quota status: user reported **62% of the 5h window consumed** at ~19:45 on 09-05.
|
||||||
|
Worker A alone cost **$2.40 / 85 turns**. Budget accordingly; prefer resuming a
|
||||||
|
session over restarting one.
|
||||||
|
|
||||||
|
### How to launch a worker
|
||||||
|
```
|
||||||
|
cd ~/bookshelf && nohup ./tasks/run-task.sh <NAME> ./tasks/<NAME>.txt >/dev/null 2>&1 &
|
||||||
|
```
|
||||||
|
`tasks/run-task.sh` is quota-aware: on a usage-limit error it sleeps `POLL` (600s) and
|
||||||
|
resumes the SAME session rather than restarting, up to `MAX_WALL` (24h), and does not
|
||||||
|
count quota waits against its 3-strike hard-failure budget. It writes:
|
||||||
|
`logs/<name>.json` (final result), `.err`, `.sid` (session id), `.state` (progress), `.summary`.
|
||||||
|
|
||||||
|
**HAZARD — do not repeat:** never edit `run-task.sh` while workers are running. Bash reads
|
||||||
|
scripts by byte offset; swapping the file mid-run makes live workers resume inside unrelated
|
||||||
|
code and can spawn duplicate `claude` processes that burn quota on finished work. If you must
|
||||||
|
change it, write a NEW file and use that for the next wave.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
- JDK 21: `~/toolchain/jdk21` (system java is 25 — too new for AGP, do not use it)
|
||||||
|
- Android SDK: `~/toolchain/android-sdk` (platforms;android-37.0, build-tools;37.0.0, platform-tools)
|
||||||
|
- **No KVM, no emulator.** Verify only via `./gradlew assembleDebug`, JVM unit tests, and
|
||||||
|
Paparazzi PNG rendering. Never claim the app was "run".
|
||||||
|
- PocketBase v0.40.2 service, **127.0.0.1:8090, deliberately NOT internet-exposed**
|
||||||
|
(no `--http-port`, so the sprite proxy can't reach it). Restart:
|
||||||
|
`sprite-env services restart pocketbase`. Logs: `/.sprite/logs/services/pocketbase.log`.
|
||||||
|
- Superuser creds: `server/.dev-credentials` (gitignored).
|
||||||
|
|
||||||
|
## STATE: what is DONE
|
||||||
|
### Wave 1A — server: COMPLETE and verified by the orchestrator (not just self-reported)
|
||||||
|
`server/` contains `setup-schema.sh` (idempotent), `create-user.sh`, `pb_hooks/main.pb.js`,
|
||||||
|
`pb_migrations/`, `deploy/` (systemd unit, Dockerfile, compose, backup.sh, README covering
|
||||||
|
Tailscale vs port-forward+Caddy), `README.md`, `.gitignore`.
|
||||||
|
|
||||||
|
Independently re-verified on 09-06 after fixing the service:
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| anonymous LIST books/shelves/bookcases | **403 / 403 / 403** |
|
||||||
|
| anonymous self-registration | **403** |
|
||||||
|
| `/api/health` | 200 |
|
||||||
|
|
||||||
|
**`pb_hooks/main.pb.js` is INTENTIONAL, not scope drift.** PocketBase's `listRule` is a row
|
||||||
|
filter, so anonymous LIST would otherwise return `200 []` instead of an error. The hook forces
|
||||||
|
403. Keep it; it is why the table above passes. It is auto-loaded by the stock binary.
|
||||||
|
|
||||||
|
## STATE: what is NOT done
|
||||||
|
### Wave 1B — Android scaffold + design system: INCOMPLETE (killed mid-run by the restart)
|
||||||
|
Present: gradle wrapper, `gradle/libs.versions.toml`, `app/build.gradle.kts`,
|
||||||
|
`AndroidManifest.xml`, `proguard-rules.pro`, Literata OFL license.
|
||||||
|
Missing/unverified: ui/theme (Color/Type/Theme), the shared component set, MainActivity,
|
||||||
|
Paparazzi setup, and **any evidence the build compiles**.
|
||||||
|
|
||||||
|
**Its session SURVIVED and is resumable — prefer this over a restart (saves quota):**
|
||||||
|
`claude -p --model sonnet --permission-mode bypassPermissions --output-format json \`
|
||||||
|
` --add-dir ~/bookshelf --resume 6823e72a-69c1-486e-ae5a-18abab84529b`
|
||||||
|
with a "continue where you left off, don't restart" prompt. (Worker A's session, for
|
||||||
|
reference, is `5e3bd183-252c-4224-99b5-91779761ccbc`.)
|
||||||
|
First thing the resumed worker must do: get `./gradlew assembleDebug` GREEN. Everything
|
||||||
|
downstream is blocked on it.
|
||||||
|
|
||||||
|
### Waves 2-4 — not started. Prompts not yet written.
|
||||||
|
- **Wave 2 (parallel, after 1B is green):**
|
||||||
|
- C — data layer: Room entities/DAOs/DB, PocketBase Retrofit client + auth interceptor,
|
||||||
|
`SyncEngine` (push-then-pull, LWW, tombstones, client-generated 15-char ids), SettingsStore.
|
||||||
|
- D — metadata + scanning: Open Library + Google Books merge, ISBN-13 checksum validation,
|
||||||
|
CameraX + ML Kit continuous scanning.
|
||||||
|
- **Wave 3 (after C+D):** E — the six screens (setup, library, detail, scan, locations, settings).
|
||||||
|
- **Wave 4:** F — Paparazzi screenshots for the user to judge the look, release keystore +
|
||||||
|
signed APK, top-level README, end-to-end sync test against the live PocketBase.
|
||||||
|
|
||||||
|
## Gotchas already paid for — do not rediscover
|
||||||
|
1. **Migration filename ↔ `_migrations` desync.** Worker A renamed `1788636563_created_books.js`
|
||||||
|
to `...564...` to fix an alphabetical-replay ordering bug (`books` sorted before `shelves`,
|
||||||
|
breaking the relation). Correct for fresh instances, but the dev DB still had the old name
|
||||||
|
recorded applied, so PocketBase tried to re-create `books` and crash-looped 9 times.
|
||||||
|
Fixed via `UPDATE _migrations SET file=...`. **If you ever rename a migration, update that
|
||||||
|
table too.** DB backup: scratchpad `data.db.bak`.
|
||||||
|
2. Rule semantics: in PocketBase `""` means PUBLIC, `null` means superuser-only. Confusing these
|
||||||
|
is exactly how the library would end up world-readable.
|
||||||
|
3. `claude -p --output-format json` writes its log only at exit; a 0-byte `.json` means the
|
||||||
|
worker is still running or was killed, not that it failed.
|
||||||
|
4. System JDK is 25 and will break AGP. Workers must export `JAVA_HOME=~/toolchain/jdk21`
|
||||||
|
(run-task.sh already does).
|
||||||
|
|
||||||
|
## Verification standard (hold workers to this)
|
||||||
|
Workers self-report optimistically. Before accepting any wave:
|
||||||
|
- Re-run the security curls above yourself. The user's stated requirement is that this not be
|
||||||
|
"accessible to everyone in the world"; that check is non-negotiable and cheap.
|
||||||
|
- Require `assembleDebug` + `test` exit 0, and confirm artifacts exist on disk.
|
||||||
|
- Treat "I couldn't get Paparazzi working so I skipped screenshots" as a finding to report to
|
||||||
|
the user, not something to paper over — the user explicitly cares how this looks.
|
||||||
|
|
||||||
|
## Open questions for the user (not yet asked — deferred, not forgotten)
|
||||||
|
- Where the server will actually live (home box vs a sprite) — only affects the deploy README.
|
||||||
|
- Their two account emails, for `create-user.sh`. Not needed until the app can log in.
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Bookshelf — authoritative spec
|
||||||
|
|
||||||
|
Two-person shared home library. Android app + self-hosted PocketBase.
|
||||||
|
ALL workers must follow this exactly. Do not invent alternative names.
|
||||||
|
|
||||||
|
## Non-negotiables
|
||||||
|
- Offline-first. Home server is often unreachable (residential NAT). Every read
|
||||||
|
comes from Room. Every write lands in Room first, syncs later. No screen may
|
||||||
|
block on network.
|
||||||
|
- Private. No public registration. Auth required for all data access.
|
||||||
|
- Server URL is NOT hardcoded; user enters it on first run.
|
||||||
|
|
||||||
|
## Repo layout
|
||||||
|
~/bookshelf/
|
||||||
|
server/ PocketBase binary(gitignored), pb_migrations/, setup-schema.sh, deploy/
|
||||||
|
app/ Android Gradle project
|
||||||
|
docs/ this spec
|
||||||
|
|
||||||
|
## Android
|
||||||
|
- applicationId/namespace: org.modg.bookshelf
|
||||||
|
- minSdk 26, compileSdk 37, targetSdk 37, JDK 21, Kotlin, Jetpack Compose, Material 3
|
||||||
|
- SDK at ~/toolchain/android-sdk ; JDK at ~/toolchain/jdk21
|
||||||
|
- DI: manual `AppContainer` held by Application. NO Hilt/kapt. Room uses KSP.
|
||||||
|
- Libs: Compose BOM, room(+ksp), retrofit2 + kotlinx-serialization converter,
|
||||||
|
okhttp logging, coil3 compose, camerax(core/camera2/lifecycle/view),
|
||||||
|
com.google.mlkit:barcode-scanning, androidx.work runtime-ktx, datastore-preferences,
|
||||||
|
navigation-compose, lifecycle-viewmodel-compose, accompanist-permissions (or manual)
|
||||||
|
|
||||||
|
## Package structure (org.modg.bookshelf.*)
|
||||||
|
data.local Room: entities, daos, BookshelfDatabase, Converters
|
||||||
|
data.remote PocketBaseApi (retrofit), dtos, PbAuthInterceptor
|
||||||
|
data.metadata OpenLibrary + GoogleBooks lookup
|
||||||
|
data.repo BookRepository, LocationRepository, SyncEngine, AuthRepository
|
||||||
|
data.prefs SettingsStore (DataStore)
|
||||||
|
ui.theme Color/Type/Theme
|
||||||
|
ui.library, ui.detail, ui.scan, ui.locations, ui.settings, ui.setup
|
||||||
|
ui.nav BookshelfNavHost
|
||||||
|
|
||||||
|
## Data model — Room mirrors PocketBase 1:1
|
||||||
|
IDs: 15-char lowercase alnum, GENERATED CLIENT-SIDE for new records
|
||||||
|
(PocketBase accepts client-supplied ids on create). Never remap ids after push.
|
||||||
|
|
||||||
|
BookEntity(id PK, title, subtitle, authorsJson, isbn13, isbn10, publisher,
|
||||||
|
publishedDate, pageCount:Int?, description, coverUrl, coverSourceUrl,
|
||||||
|
shelfId:String?, notes, addedBy, deleted:Boolean, createdAt:Long, updatedAt:Long,
|
||||||
|
syncState:SyncState, localCoverPath:String?)
|
||||||
|
BookcaseEntity(id PK, name, note, position:Int, deleted, createdAt, updatedAt, syncState)
|
||||||
|
ShelfEntity(id PK, bookcaseId, label, position:Int, deleted, createdAt, updatedAt, syncState)
|
||||||
|
|
||||||
|
enum SyncState { SYNCED, PENDING_CREATE, PENDING_UPDATE, PENDING_DELETE }
|
||||||
|
|
||||||
|
All queries filter `deleted = 0`. Deletion is ALWAYS soft (tombstone) so sync can
|
||||||
|
propagate it and nothing is silently lost from a shared library.
|
||||||
|
|
||||||
|
## PocketBase schema (collections)
|
||||||
|
bookcases: name(text,req), note(text), position(number), deleted(bool)
|
||||||
|
shelves: bookcase(relation->bookcases,req,maxSelect 1), label(text,req),
|
||||||
|
position(number), deleted(bool)
|
||||||
|
books: title(text,req), subtitle(text), authors(json), isbn13(text), isbn10(text),
|
||||||
|
publisher(text), published_date(text), page_count(number), description(text),
|
||||||
|
cover(file,maxSelect 1,image mimes,thumbs 100x150+300x450),
|
||||||
|
cover_source_url(text), shelf(relation->shelves,maxSelect 1),
|
||||||
|
notes(text), added_by(relation->users,maxSelect 1), deleted(bool)
|
||||||
|
All three get autodate created/updated.
|
||||||
|
Indexes: books(isbn13), books(updated), shelves(updated), bookcases(updated).
|
||||||
|
|
||||||
|
API rules — all of list/view/create/update/delete on the three collections:
|
||||||
|
"@request.auth.id != \"\""
|
||||||
|
users collection: createRule = null (SUPERUSER ONLY — this is what keeps the
|
||||||
|
world out), listRule/viewRule = "@request.auth.id != \"\"",
|
||||||
|
updateRule = "id = @request.auth.id", deleteRule = null.
|
||||||
|
NOTE: rule "" means PUBLIC in PocketBase; null means superuser-only. Do not confuse.
|
||||||
|
|
||||||
|
## Sync design (SyncEngine)
|
||||||
|
Pull: GET /api/collections/{c}/records?filter=(updated>'{cursor}')&sort=updated
|
||||||
|
&perPage=200&page=N — paginate to exhaustion. Cursor per collection in
|
||||||
|
DataStore, stored as PB UTC string. Include tombstones.
|
||||||
|
Push: records where syncState != SYNCED. PENDING_CREATE -> POST (with our id),
|
||||||
|
PENDING_UPDATE -> PATCH, PENDING_DELETE -> PATCH {deleted:true}.
|
||||||
|
On 404 for update/delete: drop local record. On 409/duplicate id: switch to PATCH.
|
||||||
|
Order: push THEN pull (so our writes come back canonical).
|
||||||
|
Conflict: last-write-wins on `updated`. Document this in README; do not build
|
||||||
|
anything cleverer.
|
||||||
|
Covers: on save, app downloads cover from metadata source and multipart-uploads it
|
||||||
|
to the book's `cover` file field, so covers survive upstream URL rot. If offline,
|
||||||
|
store localCoverPath and upload on next sync.
|
||||||
|
Trigger: app start, manual pull-to-refresh, WorkManager periodic (~6h, network-constrained).
|
||||||
|
Never let sync failure surface as a crash or a blocking dialog — a quiet status line only.
|
||||||
|
|
||||||
|
## Book metadata lookup
|
||||||
|
Primary Open Library: https://openlibrary.org/api/books?bibkeys=ISBN:{isbn}&format=json&jscmd=data
|
||||||
|
Fallback Google Books: https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn} (no key)
|
||||||
|
Cover: https://covers.openlibrary.org/b/isbn/{isbn}-L.jpg else GB imageLinks (force https, zoom=2)
|
||||||
|
Merge: prefer whichever has a title; fill blanks from the other. Return null if both miss,
|
||||||
|
and the UI must then offer manual entry pre-filled with the scanned ISBN.
|
||||||
|
|
||||||
|
## Barcode scanning
|
||||||
|
CameraX Preview + ImageAnalysis -> ML Kit BarcodeScanning (EAN_13, EAN_8, UPC_A).
|
||||||
|
Validate ISBN-13 checksum before lookup; ignore non-book barcodes. Debounce repeats.
|
||||||
|
Continuous mode: after a save, stay on camera for the next book (shelving a box of
|
||||||
|
books is the real use case). Show a running "added this session" count.
|
||||||
|
Handle: camera permission denial, torch toggle, and a manual-ISBN-entry escape hatch.
|
||||||
|
|
||||||
|
## Design language — "feels like books"
|
||||||
|
Warm paper, dark mahogany, gold + silver metallics. Restrained, not skeuomorphic.
|
||||||
|
Light: paper #F5EDE0, paperAlt #EDE3D2, ink #2B211A, inkSoft #5A4A3D,
|
||||||
|
mahogany #5C2E23, mahoganyDeep #3E1E17, gold #C0932F, goldSoft #D9B45B,
|
||||||
|
silver #9CA3AF, silverSoft #C7CCD1
|
||||||
|
Dark: ground #1C1411, surface #241A15, paperText #E8DCC8, mahogany #7A3E2F,
|
||||||
|
gold #D9B45B, silver #C7CCD1
|
||||||
|
Type: serif display (Literata, OFL, bundle the TTF) for titles/headers;
|
||||||
|
system sans for body/UI. Generous line-height.
|
||||||
|
Motifs: subtle spine/edge treatments, thin gold hairline rules, gentle paper-grain
|
||||||
|
on large surfaces. Covers are the hero — let them carry the color.
|
||||||
|
Both light and dark themes required. Dynamic color OFF (it would fight the palette).
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
setup First run: server URL (+ https scheme validation, trailing-slash strip,
|
||||||
|
reachability probe), email, password. Clear errors for wrong URL vs bad creds.
|
||||||
|
library Cover grid (2-3 col adaptive). Search title/author/ISBN. Filter by
|
||||||
|
bookcase/shelf. Sort title/author/added. Empty state invites first scan.
|
||||||
|
FAB -> scan. Sync status line.
|
||||||
|
detail Big cover, title/subtitle/authors/publisher/year/pages/ISBN, description
|
||||||
|
(collapsible), notes (editable), location picker, edit, soft-delete w/ undo.
|
||||||
|
scan Camera + reticle; on hit -> bottom sheet w/ fetched book + shelf picker +
|
||||||
|
Save / Skip. Duplicate-ISBN warning if already owned.
|
||||||
|
locations Bookcases -> shelves tree. CRUD + reorder. Book counts per shelf.
|
||||||
|
Tap a shelf -> library filtered to it. "Move books" bulk action.
|
||||||
|
settings Server, account, sign out, manual sync + last-sync time, book/cover counts.
|
||||||
|
|
||||||
|
## Quality bar
|
||||||
|
- No emulator on this box (no KVM). Verify via: `./gradlew assembleDebug`,
|
||||||
|
JVM unit tests, and Paparazzi screenshot rendering.
|
||||||
|
- Unit-test the real logic: ISBN checksum, metadata merge, sync conflict resolution,
|
||||||
|
DAO queries (Robolectric). Do not write assertion-free tests.
|
||||||
|
- App must compile and run with NO server configured (setup screen) and must not
|
||||||
|
crash when the server is unreachable.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
5e3bd183-252c-4224-99b5-91779761ccbc
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
6823e72a-69c1-486e-ae5a-18abab84529b
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
A-server=5e3bd183-252c-4224-99b5-91779761ccbc
|
||||||
|
B-scaffold=6823e72a-69c1-486e-ae5a-18abab84529b
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Superuser credentials for the dev instance — never commit these.
|
||||||
|
.dev-credentials
|
||||||
|
|
||||||
|
# The PocketBase binary is platform-specific and large; fetch/build it
|
||||||
|
# per docs/SPEC.md and deploy/README.md instead of vendoring it in git.
|
||||||
|
pocketbase
|
||||||
|
pocketbase.exe
|
||||||
|
|
||||||
|
# Runtime data — the whole point of pb_migrations/ is that this is
|
||||||
|
# reproducible from scratch; the actual database/uploads are not source.
|
||||||
|
pb_data/
|
||||||
|
|
||||||
|
# Local backup artifacts produced by deploy/backup.sh.
|
||||||
|
deploy/backups/
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
## v0.40.2
|
||||||
|
|
||||||
|
- Return an error when filter params fallback fails to json serialize and optimized params replacement to execute in a single pass.
|
||||||
|
|
||||||
|
- Fixed collection index parsing error for indexes with missing name.
|
||||||
|
|
||||||
|
- Minor UI autocomplete optimizations _(prefix match, autocomplete debounce, etc.)_.
|
||||||
|
|
||||||
|
- Fixed linter warnings and comment typos.
|
||||||
|
|
||||||
|
- Bumped goja and its related dependencies _(regex unescaped dash error fix and base64 optimizations)_.
|
||||||
|
|
||||||
|
- Bumped the min Go GitHub action version to 1.27.1 as it includes some [minor `database/sql` and `enconding/json/v2` bug fixes](https://github.com/golang/go/issues?q=milestone%3AGo1.27.1).
|
||||||
|
|
||||||
|
|
||||||
|
## v0.40.1
|
||||||
|
|
||||||
|
- Fixes for some reported regressions related to the `encoding/json/v2` update:
|
||||||
|
- allow mangling invalid UTF8 characters when serializing json data ([#7814](https://github.com/pocketbase/pocketbase/issues/7814))
|
||||||
|
- fixed OAuth2 providers config merge incorrectly replacing the entire slice ([#7815](https://github.com/pocketbase/pocketbase/issues/7815))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.40.0
|
||||||
|
|
||||||
|
- Propagate console command errors and recovered panics to `app.Start()` so that the program can exit with non-zero code while still ensuring that `app.OnTerminate` hook was triggered _(responsible for the app graceful shutdown handling)_.
|
||||||
|
_⚠️ Note that this could be a slight breaking change in case you are chaining PocketBase commands and relied on the previous `0` exit status for `Command.RunE` returned errors._
|
||||||
|
_Or in other words, if you have `./pocketbase invalid && someothercommand` and previously relied that `someothercommand` will be always executed then this is no longer the case and you'll have to adjust it or replace `&&` with `;`._
|
||||||
|
|
||||||
|
- Added quotes around the default `Content-Disposition` serving filename in case custom name with special characters is provided.
|
||||||
|
|
||||||
|
- Added `Cross-Origin-Opener-Policy:same-origin` to the default security response headers.
|
||||||
|
_This is an extra precaution to prevent tab-nabbing in case custom UI plugins use `target="_blank"` without `rel="noopener"`._
|
||||||
|
|
||||||
|
- Added `Record.GetInt64(field)` helper (note that the serializable max safe integer of the `number` field is ~2^53-1).
|
||||||
|
|
||||||
|
- Added `Store.Keys()` method that returns a slice with all of the store keys.
|
||||||
|
|
||||||
|
- Added new `DELETE /api/logs` endpoint and UI control to delete all logs without changing the `maxDays` retention setting.
|
||||||
|
|
||||||
|
- Added new log settings option to limit the max `Log.Data` size that will be saved in the database (default to ~16KB).
|
||||||
|
_This is an extra precaution for the cases when logging user supplied data without validating it beforehand._
|
||||||
|
_If the resulting `Log.Data` json is above the limit, it is truncated to the last valid decoded character and an extra `"__pb_truncated__":true` log data entry will be added.`_
|
||||||
|
_Additionally, for just in case the log message is also truncated at max 8k characters._
|
||||||
|
|
||||||
|
- Added new `filesystem` low-level helper methods:
|
||||||
|
- `filesystem.NewWriter(key, opts)` to allow direct file create from an `io.Reader` value.
|
||||||
|
- `filesystem.OnNewWriter()` hook to allow listening for new/to-be-created files _(it is not exposed in `core.App` instance for now to avoid introducing breaking changes)_.
|
||||||
|
- `filesystem.OnDelete()` hook to allow listening for deleted files _(it is not exposed in `core.App` instance for now to avoid introducing breaking changes)_.
|
||||||
|
|
||||||
|
- Optimized backups to no longer transaction lock the database during backup generation ([#7799](https://github.com/pocketbase/pocketbase/discussions/7799#discussioncomment-18108244)).
|
||||||
|
|
||||||
|
- Updated `modernc.org/sqlite` to 1.57.0 and registered by default the new `_defensive=1` DSN query parameter to enable [SQLite's defensive mode](https://sqlite.org/c3ref/c_dbconfig_defensive.html#sqlitedbconfigdefensive).
|
||||||
|
|
||||||
|
- Bumped the min Go version to 1.27.0 and migrated to the new `encoding/json/v2` package.
|
||||||
|
_⚠️ Please note that Go 1.27.0 retrofitted `encoding/json` to use the v2 package under the hood but unfortunately is not fully backward compatible._
|
||||||
|
_I recommend to not push blindly an update on production and to test your PocketBase application first locally to see if everything works correctly._
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
Copyright (c) 2022 - present, Gani Georgiev
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
||||||
|
and associated documentation files (the "Software"), to deal in the Software without restriction,
|
||||||
|
including without limitation the rights to use, copy, modify, merge, publish, distribute,
|
||||||
|
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software
|
||||||
|
is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or
|
||||||
|
substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
||||||
|
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||||
|
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||||
|
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# Bookshelf server
|
||||||
|
|
||||||
|
PocketBase backend for the Bookshelf app — see `../docs/SPEC.md` for the
|
||||||
|
authoritative data model and API rules. This directory contains everything
|
||||||
|
needed to provision and run it.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
server/
|
||||||
|
pocketbase PocketBase v0.40.2 binary (gitignored — platform-specific)
|
||||||
|
pb_data/ SQLite databases + uploaded files (gitignored — runtime state)
|
||||||
|
pb_migrations/ Schema history, auto-captured — a fresh instance replays
|
||||||
|
these on first boot and ends up with the full schema
|
||||||
|
pb_hooks/ JS hook that closes a PocketBase quirk (see below)
|
||||||
|
setup-schema.sh Idempotent schema/rules provisioning (curl + jq only)
|
||||||
|
create-user.sh Superuser-driven account creation (no public sign-up)
|
||||||
|
deploy/ systemd unit, Docker, backups, remote-access guidance
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quickstart (local dev)
|
||||||
|
|
||||||
|
PocketBase must already be running (see `deploy/README.md` for how to run
|
||||||
|
it as a proper service; for a quick local check you can just run the binary
|
||||||
|
directly: `./pocketbase serve`).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. Provision the schema + API rules (safe to re-run).
|
||||||
|
./setup-schema.sh http://127.0.0.1:8090 <superuser-email> <superuser-password>
|
||||||
|
|
||||||
|
# 2. Create app accounts — there is no self-registration.
|
||||||
|
./create-user.sh you@example.com "a strong password" "Your Name"
|
||||||
|
```
|
||||||
|
|
||||||
|
Both scripts also read `PB_URL`/`PB_EMAIL`/`PB_PASS` from the environment,
|
||||||
|
or fall back to `.dev-credentials` (gitignored, dev-instance-only — see
|
||||||
|
`.dev-credentials` in this directory if present) if no args are given.
|
||||||
|
|
||||||
|
## Schema summary
|
||||||
|
|
||||||
|
Three collections — `bookcases`, `shelves`, `books` — each requiring
|
||||||
|
authentication for every action (list/view/create/update/delete). Deletion
|
||||||
|
in the app is always a soft `deleted` flag (tombstone), never an actual
|
||||||
|
record delete, so sync can propagate it — see `SyncEngine` in the Android
|
||||||
|
app and `docs/SPEC.md`'s Sync design section.
|
||||||
|
|
||||||
|
The built-in `users` collection is locked down: `createRule = null` means
|
||||||
|
**only a superuser can create an account** (via `create-user.sh`); regular
|
||||||
|
users can view any user (needed to resolve the `added_by` relation) and
|
||||||
|
update only their own record.
|
||||||
|
|
||||||
|
## The pb_hooks quirk
|
||||||
|
|
||||||
|
PocketBase's declarative list/search rule acts as a row-level SQL filter,
|
||||||
|
not a hard gate: an unauthenticated request against a collection whose
|
||||||
|
`listRule` requires auth still gets **`200 OK` with an empty result**,
|
||||||
|
rather than an error — because the rule can't be cleanly separated into
|
||||||
|
"deny the whole request" vs. "just don't return matching rows" (see
|
||||||
|
[pocketbase/pocketbase#6492](https://github.com/pocketbase/pocketbase/discussions/6492)).
|
||||||
|
No private data ever leaks this way, but a `200` is still the wrong signal
|
||||||
|
for "you're not allowed here." `pb_hooks/main.pb.js` adds a small
|
||||||
|
`onRecordsListRequest` hook that turns that into a `403` for the three app
|
||||||
|
collections. It's plain JS, auto-loaded by the stock `pocketbase` binary —
|
||||||
|
no recompilation, no framework — so it ships and deploys exactly like
|
||||||
|
`pb_migrations/`.
|
||||||
|
|
||||||
|
## Verifying a deployment
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Anonymous requests must be rejected (4xx) for all three collections:
|
||||||
|
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8090/api/collections/books/records
|
||||||
|
# -> 403
|
||||||
|
|
||||||
|
# Self-registration must be rejected:
|
||||||
|
curl -s -X POST http://127.0.0.1:8090/api/collections/users/records \
|
||||||
|
-H 'Content-Type: application/json' -d '{"email":"x@x.com","password":"password123","passwordConfirm":"password123"}'
|
||||||
|
# -> 403 "Only superusers can perform this action."
|
||||||
|
```
|
||||||
|
|
||||||
|
See `deploy/README.md` for running the server long-term, reaching it from
|
||||||
|
outside your home network, and backups.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# create-user.sh — superuser-driven account creation for Bookshelf.
|
||||||
|
#
|
||||||
|
# There is no self-registration path (users.createRule = null, see
|
||||||
|
# setup-schema.sh), so the only way to create an app account is for the
|
||||||
|
# server owner to run this script as the superuser. Intended for the two
|
||||||
|
# household accounts (owner + spouse), but works for any number of users.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./create-user.sh <email> <password> [name]
|
||||||
|
# PB_URL=https://bookshelf.example.com ./create-user.sh alice@example.com "correct horse battery staple" Alice
|
||||||
|
#
|
||||||
|
# PB_URL, PB_EMAIL (superuser), PB_PASS (superuser) come from env, or fall
|
||||||
|
# back to ./.dev-credentials, same as setup-schema.sh.
|
||||||
|
|
||||||
|
set -u -o pipefail
|
||||||
|
|
||||||
|
NEW_EMAIL="${1:-}"
|
||||||
|
NEW_PASS="${2:-}"
|
||||||
|
NEW_NAME="${3:-}"
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
CREDS_FILE="$SCRIPT_DIR/.dev-credentials"
|
||||||
|
|
||||||
|
log() { printf '%s\n' "$*" >&2; }
|
||||||
|
ok() { printf '\033[32m✓\033[0m %s\n' "$*" >&2; }
|
||||||
|
fail() { printf '\033[31m✗ ERROR:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
command -v curl >/dev/null 2>&1 || fail "curl is required but not installed."
|
||||||
|
command -v jq >/dev/null 2>&1 || fail "jq is required but not installed."
|
||||||
|
|
||||||
|
if [[ -z "$NEW_EMAIL" || -z "$NEW_PASS" ]]; then
|
||||||
|
fail "Usage: $0 <email> <password> [name]"
|
||||||
|
fi
|
||||||
|
if [[ ${#NEW_PASS} -lt 8 ]]; then
|
||||||
|
fail "Password must be at least 8 characters (PocketBase minimum)."
|
||||||
|
fi
|
||||||
|
|
||||||
|
PB_URL="${PB_URL:-http://127.0.0.1:8090}"
|
||||||
|
PB_URL="${PB_URL%/}"
|
||||||
|
PB_EMAIL="${PB_EMAIL:-}"
|
||||||
|
PB_PASS="${PB_PASS:-}"
|
||||||
|
|
||||||
|
if [[ -z "$PB_EMAIL" || -z "$PB_PASS" ]]; then
|
||||||
|
if [[ -f "$CREDS_FILE" ]]; then
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$CREDS_FILE"
|
||||||
|
PB_EMAIL="${PB_EMAIL:-${PB_SUPERUSER_EMAIL:-}}"
|
||||||
|
PB_PASS="${PB_PASS:-${PB_SUPERUSER_PASS:-}}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[[ -n "$PB_EMAIL" ]] || fail "No superuser email given. Set PB_EMAIL or provide $CREDS_FILE."
|
||||||
|
[[ -n "$PB_PASS" ]] || fail "No superuser password given. Set PB_PASS or provide $CREDS_FILE."
|
||||||
|
|
||||||
|
log "Creating Bookshelf account on $PB_URL"
|
||||||
|
log " Email: $NEW_EMAIL"
|
||||||
|
|
||||||
|
AUTH_RESP="$(curl -s -w '\n%{http_code}' -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n --arg identity "$PB_EMAIL" --arg password "$PB_PASS" '{identity:$identity, password:$password}')")"
|
||||||
|
AUTH_BODY="$(printf '%s' "$AUTH_RESP" | sed '$d')"
|
||||||
|
AUTH_CODE="$(printf '%s' "$AUTH_RESP" | tail -n1)"
|
||||||
|
[[ "$AUTH_CODE" == "200" ]] || fail "Superuser login failed (HTTP $AUTH_CODE): $AUTH_BODY"
|
||||||
|
TOKEN="$(printf '%s' "$AUTH_BODY" | jq -r '.token')"
|
||||||
|
|
||||||
|
PAYLOAD="$(jq -n \
|
||||||
|
--arg email "$NEW_EMAIL" \
|
||||||
|
--arg password "$NEW_PASS" \
|
||||||
|
--arg name "$NEW_NAME" \
|
||||||
|
'{email:$email, password:$password, passwordConfirm:$password, name:$name, emailVisibility:true, verified:true}')"
|
||||||
|
|
||||||
|
CREATE_RESP="$(curl -s -w '\n%{http_code}' -X POST "$PB_URL/api/collections/users/records" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d "$PAYLOAD")"
|
||||||
|
CREATE_BODY="$(printf '%s' "$CREATE_RESP" | sed '$d')"
|
||||||
|
CREATE_CODE="$(printf '%s' "$CREATE_RESP" | tail -n1)"
|
||||||
|
|
||||||
|
if [[ "$CREATE_CODE" != "200" ]]; then
|
||||||
|
fail "Failed to create user (HTTP $CREATE_CODE): $CREATE_BODY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
USER_ID="$(printf '%s' "$CREATE_BODY" | jq -r '.id')"
|
||||||
|
ok "Created user '$NEW_EMAIL' (id: $USER_ID)"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Bookshelf PocketBase server — container image.
|
||||||
|
#
|
||||||
|
# The pocketbase binary is already vendored into ./server (this repo does not
|
||||||
|
# gitignore it out of the image build context — see server/.gitignore, which
|
||||||
|
# only excludes it from *git*). If you'd rather fetch it fresh, replace the
|
||||||
|
# COPY below with a curl of the official release for your architecture from
|
||||||
|
# https://github.com/pocketbase/pocketbase/releases (match the version
|
||||||
|
# already pinned in server/pocketbase --version, currently v0.40.2).
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /pb
|
||||||
|
|
||||||
|
COPY pocketbase /pb/pocketbase
|
||||||
|
COPY pb_migrations /pb/pb_migrations
|
||||||
|
COPY pb_hooks /pb/pb_hooks
|
||||||
|
RUN chmod +x /pb/pocketbase
|
||||||
|
|
||||||
|
# pb_data is a volume — see docker-compose.yml. Do not bake data into the
|
||||||
|
# image; it must survive container recreation.
|
||||||
|
VOLUME /pb/pb_data
|
||||||
|
|
||||||
|
EXPOSE 8090
|
||||||
|
|
||||||
|
ENTRYPOINT ["/pb/pocketbase"]
|
||||||
|
CMD ["serve", "--http=0.0.0.0:8090", "--dir=/pb/pb_data", "--migrationsDir=/pb/pb_migrations", "--hooksDir=/pb/pb_hooks"]
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# Deploying Bookshelf's PocketBase server
|
||||||
|
|
||||||
|
This assumes a spare always-on machine at home (a mini PC, NUC, Raspberry Pi,
|
||||||
|
or an old laptop) running Linux. Pick **one** of the two run methods below —
|
||||||
|
systemd or Docker — not both.
|
||||||
|
|
||||||
|
## 0. Get the files onto the server
|
||||||
|
|
||||||
|
Copy the whole `server/` directory (minus `.dev-credentials`, which is
|
||||||
|
gitignored and dev-only) to the target machine, e.g.:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rsync -av --exclude .dev-credentials ~/bookshelf/server/ youruser@homeserver:/opt/bookshelf/
|
||||||
|
```
|
||||||
|
|
||||||
|
The `pocketbase` binary is architecture-specific — if your home server isn't
|
||||||
|
the same CPU architecture as wherever you built/downloaded it, grab the
|
||||||
|
matching build from https://github.com/pocketbase/pocketbase/releases
|
||||||
|
(this project is pinned to **v0.40.2**) and drop it in as `/opt/bookshelf/pocketbase`.
|
||||||
|
|
||||||
|
## 1. Run it — Option A: systemd (recommended for a bare-metal/VM host)
|
||||||
|
|
||||||
|
1. Create a dedicated unprivileged user:
|
||||||
|
```sh
|
||||||
|
sudo useradd --system --home /opt/bookshelf --shell /usr/sbin/nologin bookshelf
|
||||||
|
sudo chown -R bookshelf:bookshelf /opt/bookshelf
|
||||||
|
```
|
||||||
|
2. Install the unit:
|
||||||
|
```sh
|
||||||
|
sudo cp deploy/bookshelf.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now bookshelf
|
||||||
|
```
|
||||||
|
3. Check it's up: `systemctl status bookshelf` and `curl http://127.0.0.1:8090/api/health`.
|
||||||
|
4. Logs: `journalctl -u bookshelf -f`.
|
||||||
|
|
||||||
|
Note the unit binds PocketBase to `127.0.0.1:8090` only — it is **not**
|
||||||
|
reachable from other machines yet. That's intentional; see step 4.
|
||||||
|
|
||||||
|
## 2. Run it — Option B: Docker
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd deploy
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
This also only publishes to `127.0.0.1:8090` on the host, for the same
|
||||||
|
reason. Data persists in the `bookshelf_pb_data` named volume regardless of
|
||||||
|
container restarts/rebuilds.
|
||||||
|
|
||||||
|
## 3. Provision the schema and accounts
|
||||||
|
|
||||||
|
Once the server is up and answering on `127.0.0.1:8090`, run the setup
|
||||||
|
scripts from `server/` (one directory up from here):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /opt/bookshelf
|
||||||
|
|
||||||
|
# If this is a brand-new PocketBase data dir, it prints a one-time setup URL
|
||||||
|
# on first launch (see `journalctl -u bookshelf` or `docker compose logs`) —
|
||||||
|
# open that in a browser first to create your superuser account.
|
||||||
|
|
||||||
|
./setup-schema.sh http://127.0.0.1:8090 <superuser-email> <superuser-password>
|
||||||
|
|
||||||
|
# Then create the household accounts — there is no public sign-up:
|
||||||
|
./create-user.sh owner@example.com "a strong password" "Owner Name"
|
||||||
|
./create-user.sh spouse@example.com "a different strong password" "Spouse Name"
|
||||||
|
```
|
||||||
|
|
||||||
|
`setup-schema.sh` is idempotent — safe to re-run any time (e.g. after
|
||||||
|
pulling an updated `server/` if the schema ever changes).
|
||||||
|
|
||||||
|
## 4. Reach it from outside your home network
|
||||||
|
|
||||||
|
The app needs a URL it can hit from anywhere your phone goes — not just your
|
||||||
|
home wifi. Two real options:
|
||||||
|
|
||||||
|
### Tailscale (recommended)
|
||||||
|
|
||||||
|
Install Tailscale on the home server and on your phone, join both to the
|
||||||
|
same tailnet. The server gets a stable `100.x.y.z` address (or a
|
||||||
|
MagicDNS name like `homeserver.your-tailnet.ts.net`) reachable from
|
||||||
|
anywhere, encrypted end-to-end, with **no ports opened on your router**.
|
||||||
|
|
||||||
|
- **Pros:** essentially zero attack surface (nothing is exposed to the
|
||||||
|
public internet at all — not even a login page), no TLS cert management,
|
||||||
|
works behind CGNAT, five-minute setup.
|
||||||
|
- **Cons:** both devices need the Tailscale app installed and signed in;
|
||||||
|
if Tailscale's coordination service has an outage, *new* connections may
|
||||||
|
be briefly unable to establish (existing ones keep working) — acceptable
|
||||||
|
for a two-person home library, not for something needing five-nines.
|
||||||
|
- Point the app's server-URL field at `http://100.x.y.z:8090` (Tailscale's
|
||||||
|
encryption makes plain HTTP tolerable *inside the tailnet*, but see the
|
||||||
|
HTTPS note below — using `https://` via Tailscale Serve, next, is easy
|
||||||
|
enough to just do).
|
||||||
|
- Even better: use [`tailscale serve`](https://tailscale.com/kb/1312/serve)
|
||||||
|
to get automatic HTTPS with a real cert on your tailnet domain, so the
|
||||||
|
app can just always use `https://`:
|
||||||
|
```sh
|
||||||
|
sudo tailscale serve --bg 8090
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port-forward + Caddy (reverse proxy with TLS)
|
||||||
|
|
||||||
|
Forward a port on your router to the home server, and run
|
||||||
|
[Caddy](https://caddyserver.com/) in front of PocketBase to terminate TLS
|
||||||
|
with an automatic Let's Encrypt certificate.
|
||||||
|
|
||||||
|
- **Pros:** works with any client, no extra app/agent needed on the phone,
|
||||||
|
a real public HTTPS URL.
|
||||||
|
- **Cons:** you're now running an internet-facing service from your home —
|
||||||
|
bugs in PocketBase, Caddy, or your router's firmware are now a real
|
||||||
|
attack surface; you need a domain name (or dynamic-DNS if your ISP gives
|
||||||
|
you a changing IP) for Let's Encrypt's HTTP-01/TLS-ALPN-01 challenge to
|
||||||
|
work; residential ISPs sometimes block inbound 80/443 or use CGNAT,
|
||||||
|
which breaks this approach entirely (Tailscale sidesteps that).
|
||||||
|
|
||||||
|
Minimal Caddyfile:
|
||||||
|
```
|
||||||
|
bookshelf.yourdomain.com {
|
||||||
|
reverse_proxy 127.0.0.1:8090
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Caddy handles the certificate automatically. Point your router's port
|
||||||
|
forward at the Caddy host's 443, and the app's server-URL field at
|
||||||
|
`https://bookshelf.yourdomain.com`.
|
||||||
|
|
||||||
|
### HTTPS is not optional
|
||||||
|
|
||||||
|
The app sends the account password on every login. **Never** point the app
|
||||||
|
at a plain `http://` URL that crosses the public internet or an untrusted
|
||||||
|
network — only plain HTTP over Tailscale (which is already
|
||||||
|
end-to-end-encrypted at the network layer) is acceptable, and even there,
|
||||||
|
prefer `tailscale serve` for a real cert. If you go the port-forward route,
|
||||||
|
Caddy above gets you HTTPS for free — don't skip it.
|
||||||
|
|
||||||
|
## 5. Back up regularly
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./deploy/backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
See the comments at the top of `backup.sh` for what it does (SQLite-safe
|
||||||
|
snapshot + uploaded covers, tarball, prune old ones) and the restore
|
||||||
|
procedure. Wire it into cron:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
crontab -e
|
||||||
|
# add:
|
||||||
|
0 3 * * * /opt/bookshelf/deploy/backup.sh >> /var/log/bookshelf-backup.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
Consider also copying the resulting tarballs off-box (another machine, a
|
||||||
|
USB drive, cloud storage) — a backup that lives on the same disk as the
|
||||||
|
data it's backing up doesn't protect against disk failure.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# backup.sh — SQLite-safe backup of a running PocketBase pb_data directory.
|
||||||
|
#
|
||||||
|
# PocketBase's data.db and auxiliary.db are SQLite databases opened in WAL
|
||||||
|
# mode. Copying the .db files directly with `cp` while the server is running
|
||||||
|
# can grab an inconsistent snapshot if a checkpoint happens mid-copy. Using
|
||||||
|
# sqlite3's `.backup` command (or PocketBase's own "backups" API) takes a
|
||||||
|
# consistent snapshot safely without stopping the server.
|
||||||
|
#
|
||||||
|
# This script uses `sqlite3 .backup`, which is the simplest option that needs
|
||||||
|
# no PocketBase superuser credentials. It backs up pb_data (including
|
||||||
|
# uploaded files) into timestamped tarballs and prunes old ones.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./backup.sh [PB_DATA_DIR] [BACKUP_DIR]
|
||||||
|
# PB_DATA_DIR defaults to ../pb_data (relative to this script)
|
||||||
|
# BACKUP_DIR defaults to ./backups (relative to this script)
|
||||||
|
#
|
||||||
|
# Suggested cron (nightly at 3am, keep last 14):
|
||||||
|
# 0 3 * * * /opt/bookshelf/deploy/backup.sh >> /var/log/bookshelf-backup.log 2>&1
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PB_DATA_DIR="${1:-$SCRIPT_DIR/../pb_data}"
|
||||||
|
BACKUP_DIR="${2:-$SCRIPT_DIR/backups}"
|
||||||
|
KEEP_LAST="${KEEP_LAST:-14}"
|
||||||
|
|
||||||
|
log() { printf '[%s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$*"; }
|
||||||
|
fail() { log "ERROR: $*"; exit 1; }
|
||||||
|
|
||||||
|
command -v sqlite3 >/dev/null 2>&1 || fail "sqlite3 is required (apt install sqlite3 / apk add sqlite)."
|
||||||
|
[[ -d "$PB_DATA_DIR" ]] || fail "pb_data directory not found: $PB_DATA_DIR"
|
||||||
|
|
||||||
|
TIMESTAMP="$(date -u '+%Y%m%d_%H%M%S')"
|
||||||
|
WORKDIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$WORKDIR"' EXIT
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
log "Backing up pb_data from $PB_DATA_DIR"
|
||||||
|
|
||||||
|
# Consistent SQLite snapshots via the .backup command (safe for a live,
|
||||||
|
# WAL-mode database — it's the same mechanism `sqlite3 db.sqlite .backup`
|
||||||
|
# uses, which briefly locks for the copy but never corrupts).
|
||||||
|
for db in data.db auxiliary.db; do
|
||||||
|
src="$PB_DATA_DIR/$db"
|
||||||
|
if [[ -f "$src" ]]; then
|
||||||
|
log " snapshotting $db"
|
||||||
|
sqlite3 "$src" ".backup '$WORKDIR/$db'"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Everything else under pb_data that isn't a sqlite file/WAL/SHM (i.e.
|
||||||
|
# uploaded covers under storage/) gets copied as-is — these are immutable
|
||||||
|
# blobs once written, so a plain copy is safe.
|
||||||
|
log " copying uploaded files (storage/)"
|
||||||
|
if [[ -d "$PB_DATA_DIR/storage" ]]; then
|
||||||
|
cp -a "$PB_DATA_DIR/storage" "$WORKDIR/storage"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ARCHIVE="$BACKUP_DIR/bookshelf-backup-$TIMESTAMP.tar.gz"
|
||||||
|
tar -czf "$ARCHIVE" -C "$WORKDIR" .
|
||||||
|
log "Wrote $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))"
|
||||||
|
|
||||||
|
# Prune old backups, keep the most recent $KEEP_LAST.
|
||||||
|
mapfile -t old < <(ls -1t "$BACKUP_DIR"/bookshelf-backup-*.tar.gz 2>/dev/null | tail -n +$((KEEP_LAST + 1)))
|
||||||
|
if [[ ${#old[@]} -gt 0 ]]; then
|
||||||
|
log "Pruning ${#old[@]} backup(s) older than the last $KEEP_LAST"
|
||||||
|
rm -f "${old[@]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Done."
|
||||||
|
|
||||||
|
# --- Restore ---------------------------------------------------------------
|
||||||
|
# 1. Stop the server: sudo systemctl stop bookshelf
|
||||||
|
# 2. Move aside the live data: mv /opt/bookshelf/pb_data /opt/bookshelf/pb_data.bak
|
||||||
|
# 3. Extract the chosen backup: mkdir /opt/bookshelf/pb_data && \
|
||||||
|
# tar -xzf bookshelf-backup-TIMESTAMP.tar.gz -C /opt/bookshelf/pb_data
|
||||||
|
# 4. Fix ownership: sudo chown -R bookshelf:bookshelf /opt/bookshelf/pb_data
|
||||||
|
# 5. Start the server again: sudo systemctl start bookshelf
|
||||||
|
# 6. Once you've confirmed it's healthy, delete pb_data.bak.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Bookshelf PocketBase server
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
# Adjust to your setup: a dedicated, unprivileged system account.
|
||||||
|
# Create it once with: sudo useradd --system --home /opt/bookshelf --shell /usr/sbin/nologin bookshelf
|
||||||
|
User=bookshelf
|
||||||
|
Group=bookshelf
|
||||||
|
|
||||||
|
# All paths below assume the whole ~/bookshelf/server tree (pocketbase binary,
|
||||||
|
# pb_data/, pb_migrations/, pb_hooks/) is deployed to /opt/bookshelf.
|
||||||
|
# Adjust WorkingDirectory/ExecStart if you deploy elsewhere.
|
||||||
|
WorkingDirectory=/opt/bookshelf
|
||||||
|
ExecStart=/opt/bookshelf/pocketbase serve \
|
||||||
|
--http=127.0.0.1:8090 \
|
||||||
|
--dir=/opt/bookshelf/pb_data \
|
||||||
|
--migrationsDir=/opt/bookshelf/pb_migrations \
|
||||||
|
--hooksDir=/opt/bookshelf/pb_hooks
|
||||||
|
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
# Hardening (safe defaults for a single-purpose service; loosen only if
|
||||||
|
# something concrete breaks).
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=true
|
||||||
|
ReadWritePaths=/opt/bookshelf/pb_data
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
AmbientCapabilities=
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Bookshelf PocketBase server — Docker Compose deployment.
|
||||||
|
#
|
||||||
|
# Build context is the parent `server/` directory (so the Dockerfile can see
|
||||||
|
# pocketbase, pb_migrations/, pb_hooks/). Run compose from server/deploy/:
|
||||||
|
#
|
||||||
|
# cd server/deploy && docker compose up -d
|
||||||
|
#
|
||||||
|
# Data lives in the named volume `bookshelf_pb_data`, independent of the
|
||||||
|
# container lifecycle — `docker compose down` (without -v) never touches it.
|
||||||
|
#
|
||||||
|
# The port is published on 127.0.0.1 only, matching bookshelf.service: this
|
||||||
|
# container is meant to sit behind Tailscale or a Caddy reverse proxy on the
|
||||||
|
# same host, never exposed directly to the internet (see deploy/README.md).
|
||||||
|
|
||||||
|
services:
|
||||||
|
pocketbase:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: deploy/Dockerfile
|
||||||
|
container_name: bookshelf-pocketbase
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8090:8090"
|
||||||
|
volumes:
|
||||||
|
- bookshelf_pb_data:/pb/pb_data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://127.0.0.1:8090/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
bookshelf_pb_data:
|
||||||
|
name: bookshelf_pb_data
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
|
||||||
|
// PocketBase's declarative API rules act as row-level filters for the
|
||||||
|
// "list"/"search" action: an unsatisfiable listRule (e.g. requiring auth)
|
||||||
|
// still returns 200 with an empty result set rather than an error, because
|
||||||
|
// the rule is just a SQL WHERE clause under the hood. See
|
||||||
|
// https://github.com/pocketbase/pocketbase/discussions/6492
|
||||||
|
//
|
||||||
|
// Bookshelf is a private, two-person library — no anonymous caller should
|
||||||
|
// ever get a 200 back from these endpoints, even an empty one, since some
|
||||||
|
// HTTP/JS clients treat "200 with []" as a successful, allowed request.
|
||||||
|
// This hook makes that explicit: anonymous list/search requests against the
|
||||||
|
// three app collections are rejected with 403, matching create/update/
|
||||||
|
// view/delete (which already 400/404 for unauthenticated callers via the
|
||||||
|
// declarative rules alone).
|
||||||
|
onRecordsListRequest((e) => {
|
||||||
|
if (!e.auth) {
|
||||||
|
throw new ForbiddenError("Authentication required.");
|
||||||
|
}
|
||||||
|
e.next();
|
||||||
|
}, "bookcases", "shelves", "books");
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": "@request.auth.id != \"\"",
|
||||||
|
"deleteRule": "@request.auth.id != \"\"",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1579384326",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "name",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3485334036",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "note",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number1177347317",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "position",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool3946532403",
|
||||||
|
"name": "deleted",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_1015602688",
|
||||||
|
"indexes": [
|
||||||
|
"CREATE INDEX `idx_bookcases_updated` ON `bookcases` (`updated`)"
|
||||||
|
],
|
||||||
|
"listRule": "@request.auth.id != \"\"",
|
||||||
|
"name": "bookcases",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": "@request.auth.id != \"\"",
|
||||||
|
"viewRule": "@request.auth.id != \"\""
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_1015602688");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": "@request.auth.id != \"\"",
|
||||||
|
"deleteRule": "@request.auth.id != \"\"",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_1015602688",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation3290364264",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "bookcase",
|
||||||
|
"presentable": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text245846248",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "label",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number1177347317",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "position",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool3946532403",
|
||||||
|
"name": "deleted",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_2495675413",
|
||||||
|
"indexes": [
|
||||||
|
"CREATE INDEX `idx_shelves_updated` ON `shelves` (`updated`)"
|
||||||
|
],
|
||||||
|
"listRule": "@request.auth.id != \"\"",
|
||||||
|
"name": "shelves",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": "@request.auth.id != \"\"",
|
||||||
|
"viewRule": "@request.auth.id != \"\""
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2495675413");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("_pb_users_auth_")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": null,
|
||||||
|
"deleteRule": null,
|
||||||
|
"listRule": "@request.auth.id != \"\"",
|
||||||
|
"viewRule": "@request.auth.id != \"\""
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("_pb_users_auth_")
|
||||||
|
|
||||||
|
// update collection data
|
||||||
|
unmarshal({
|
||||||
|
"createRule": "",
|
||||||
|
"deleteRule": "id = @request.auth.id",
|
||||||
|
"listRule": "id = @request.auth.id",
|
||||||
|
"viewRule": "id = @request.auth.id"
|
||||||
|
}, collection)
|
||||||
|
|
||||||
|
return app.save(collection)
|
||||||
|
})
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": "@request.auth.id != \"\"",
|
||||||
|
"deleteRule": "@request.auth.id != \"\"",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3208210256",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text724990059",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "title",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1367709617",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "subtitle",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json2383161937",
|
||||||
|
"maxSize": 0,
|
||||||
|
"name": "authors",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text2325797416",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "isbn13",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text329878418",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "isbn10",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text2632504646",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "publisher",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text2651326123",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "published_date",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "number3814243252",
|
||||||
|
"max": null,
|
||||||
|
"min": null,
|
||||||
|
"name": "page_count",
|
||||||
|
"onlyInt": false,
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1843675174",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "description",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "file2366146245",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"maxSize": 0,
|
||||||
|
"mimeTypes": [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/png",
|
||||||
|
"image/webp",
|
||||||
|
"image/gif"
|
||||||
|
],
|
||||||
|
"name": "cover",
|
||||||
|
"presentable": false,
|
||||||
|
"protected": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"thumbs": [
|
||||||
|
"100x150",
|
||||||
|
"300x450"
|
||||||
|
],
|
||||||
|
"type": "file"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text2395634672",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "cover_source_url",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "pbc_2495675413",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation2772917219",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "shelf",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text18589324",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"name": "notes",
|
||||||
|
"pattern": "",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cascadeDelete": false,
|
||||||
|
"collectionId": "_pb_users_auth_",
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "relation1771793327",
|
||||||
|
"maxSelect": 1,
|
||||||
|
"minSelect": 0,
|
||||||
|
"name": "added_by",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "relation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"help": "",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "bool3946532403",
|
||||||
|
"name": "deleted",
|
||||||
|
"presentable": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "bool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2990389176",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate3332085495",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_2170393721",
|
||||||
|
"indexes": [
|
||||||
|
"CREATE INDEX `idx_books_isbn13` ON `books` (`isbn13`)",
|
||||||
|
"CREATE INDEX `idx_books_updated` ON `books` (`updated`)"
|
||||||
|
],
|
||||||
|
"listRule": "@request.auth.id != \"\"",
|
||||||
|
"name": "books",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": "@request.auth.id != \"\"",
|
||||||
|
"viewRule": "@request.auth.id != \"\""
|
||||||
|
});
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_2170393721");
|
||||||
|
|
||||||
|
return app.delete(collection);
|
||||||
|
})
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# setup-schema.sh — idempotent PocketBase schema provisioning for Bookshelf.
|
||||||
|
#
|
||||||
|
# Creates/updates the `bookcases`, `shelves`, `books` collections exactly per
|
||||||
|
# docs/SPEC.md, sets every API rule (including locking down self-registration
|
||||||
|
# on `users`), and creates the required indexes. Safe to re-run any number of
|
||||||
|
# times against the same instance — it diffs against what already exists.
|
||||||
|
#
|
||||||
|
# Requires: bash, curl, jq (no other tooling; portable to any Linux box).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./setup-schema.sh [PB_URL] [PB_EMAIL] [PB_PASS]
|
||||||
|
# PB_URL=http://127.0.0.1:8090 PB_EMAIL=admin@example.com PB_PASS=secret ./setup-schema.sh
|
||||||
|
#
|
||||||
|
# Args take priority over env vars if both are given.
|
||||||
|
|
||||||
|
set -u -o pipefail
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PB_URL="${1:-${PB_URL:-http://127.0.0.1:8090}}"
|
||||||
|
PB_EMAIL="${2:-${PB_EMAIL:-}}"
|
||||||
|
PB_PASS="${3:-${PB_PASS:-}}"
|
||||||
|
|
||||||
|
# Strip any trailing slash so URL-joining below is predictable.
|
||||||
|
PB_URL="${PB_URL%/}"
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
CREDS_FILE="$SCRIPT_DIR/.dev-credentials"
|
||||||
|
|
||||||
|
log() { printf '%s\n' "$*" >&2; }
|
||||||
|
info() { log " $*"; }
|
||||||
|
ok() { printf '\033[32m✓\033[0m %s\n' "$*" >&2; }
|
||||||
|
fail() { printf '\033[31m✗ ERROR:\033[0m %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Preflight
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
command -v curl >/dev/null 2>&1 || fail "curl is required but not installed."
|
||||||
|
command -v jq >/dev/null 2>&1 || fail "jq is required but not installed."
|
||||||
|
|
||||||
|
if [[ -z "$PB_EMAIL" || -z "$PB_PASS" ]]; then
|
||||||
|
if [[ -f "$CREDS_FILE" ]]; then
|
||||||
|
info "No PB_EMAIL/PB_PASS given — loading $CREDS_FILE"
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$CREDS_FILE"
|
||||||
|
PB_EMAIL="${PB_EMAIL:-${PB_SUPERUSER_EMAIL:-}}"
|
||||||
|
PB_PASS="${PB_PASS:-${PB_SUPERUSER_PASS:-}}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ -n "$PB_EMAIL" ]] || fail "No superuser email given. Pass it as arg 2, set PB_EMAIL, or provide $CREDS_FILE."
|
||||||
|
[[ -n "$PB_PASS" ]] || fail "No superuser password given. Pass it as arg 3, set PB_PASS, or provide $CREDS_FILE."
|
||||||
|
|
||||||
|
log "Bookshelf schema setup"
|
||||||
|
log " Target: $PB_URL"
|
||||||
|
log " Superuser: $PB_EMAIL"
|
||||||
|
log ""
|
||||||
|
|
||||||
|
# Reachability check with an actionable error.
|
||||||
|
if ! curl -sf -o /dev/null --connect-timeout 5 "$PB_URL/api/health"; then
|
||||||
|
fail "Cannot reach $PB_URL/api/health — is PocketBase running there? Check the URL and that the server is up."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auth
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
AUTH_RESP="$(curl -s -w '\n%{http_code}' -X POST "$PB_URL/api/collections/_superusers/auth-with-password" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$(jq -n --arg identity "$PB_EMAIL" --arg password "$PB_PASS" '{identity:$identity, password:$password}')")"
|
||||||
|
AUTH_BODY="$(printf '%s' "$AUTH_RESP" | sed '$d')"
|
||||||
|
AUTH_CODE="$(printf '%s' "$AUTH_RESP" | tail -n1)"
|
||||||
|
|
||||||
|
if [[ "$AUTH_CODE" != "200" ]]; then
|
||||||
|
fail "Superuser login failed (HTTP $AUTH_CODE). Response: $AUTH_BODY
|
||||||
|
Check PB_EMAIL/PB_PASS (or $CREDS_FILE) and that this account is a _superusers record."
|
||||||
|
fi
|
||||||
|
|
||||||
|
TOKEN="$(printf '%s' "$AUTH_BODY" | jq -r '.token')"
|
||||||
|
[[ -n "$TOKEN" && "$TOKEN" != "null" ]] || fail "Login succeeded but no token was returned. Response: $AUTH_BODY"
|
||||||
|
ok "Authenticated as superuser"
|
||||||
|
|
||||||
|
AUTH_HEADER="Authorization: Bearer $TOKEN"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTTP helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Both helpers below print "<http_code>\n<body>" to stdout. Callers must NOT
|
||||||
|
# rely on a global variable being set as a side effect of a function called
|
||||||
|
# inside a command substitution — that runs in a subshell, so any such global
|
||||||
|
# assignment is invisible to the caller once the subshell exits. Encoding the
|
||||||
|
# code into the captured output itself sidesteps that entirely.
|
||||||
|
|
||||||
|
# api_get PATH -> prints "<code>\n<body>"
|
||||||
|
api_get() {
|
||||||
|
local path="$1"
|
||||||
|
curl -s -w '\n%{http_code}' -H "$AUTH_HEADER" "$PB_URL$path" | _reorder_code_first
|
||||||
|
}
|
||||||
|
|
||||||
|
# api_send METHOD PATH JSON_BODY -> prints "<code>\n<body>"
|
||||||
|
api_send() {
|
||||||
|
local method="$1" path="$2" body="$3"
|
||||||
|
curl -s -w '\n%{http_code}' -X "$method" "$PB_URL$path" \
|
||||||
|
-H "$AUTH_HEADER" -H "Content-Type: application/json" -d "$body" | _reorder_code_first
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reads "<body...>\n<code>" (curl's -w appends code as the final line) and
|
||||||
|
# re-emits it as "<code>\n<body...>" so callers can cleanly read one line for
|
||||||
|
# the code and the rest for the body.
|
||||||
|
_reorder_code_first() {
|
||||||
|
local all code body
|
||||||
|
all="$(cat)"
|
||||||
|
code="$(printf '%s' "$all" | tail -n1)"
|
||||||
|
body="$(printf '%s' "$all" | sed '$d')"
|
||||||
|
printf '%s\n%s' "$code" "$body"
|
||||||
|
}
|
||||||
|
|
||||||
|
# split_response VAR_PREFIX RESPONSE — sets ${VAR_PREFIX}_CODE and ${VAR_PREFIX}_BODY
|
||||||
|
# from a "<code>\n<body>" string, in the *caller's* shell (no subshell).
|
||||||
|
split_response() {
|
||||||
|
local prefix="$1" resp="$2"
|
||||||
|
printf -v "${prefix}_CODE" '%s' "$(printf '%s' "$resp" | head -n1)"
|
||||||
|
printf -v "${prefix}_BODY" '%s' "$(printf '%s' "$resp" | tail -n +2)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# get_collection_json NAME -> prints collection JSON, or empty string if 404
|
||||||
|
get_collection_json() {
|
||||||
|
local name="$1" resp
|
||||||
|
resp="$(api_get "/api/collections/$name")"
|
||||||
|
split_response GC "$resp"
|
||||||
|
if [[ "$GC_CODE" == "200" ]]; then
|
||||||
|
printf '%s' "$GC_BODY"
|
||||||
|
elif [[ "$GC_CODE" == "404" ]]; then
|
||||||
|
printf ''
|
||||||
|
else
|
||||||
|
fail "Unexpected response fetching collection '$name' (HTTP $GC_CODE): $GC_BODY"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Field merging
|
||||||
|
#
|
||||||
|
# PocketBase's collection PATCH/POST replaces the *entire* fields array with
|
||||||
|
# whatever is sent — any field omitted from the payload is dropped. To stay
|
||||||
|
# idempotent (and to keep each field's existing internal id stable across
|
||||||
|
# re-runs, which matters for indexes and relations), we merge our desired
|
||||||
|
# field definitions over whatever fields already exist by matching on name:
|
||||||
|
# existing fields get their "id" preserved and properties overwritten; new
|
||||||
|
# fields are appended with no id (PocketBase assigns one on save).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# merge_fields EXISTING_FIELDS_JSON DESIRED_FIELDS_JSON -> merged fields array
|
||||||
|
merge_fields() {
|
||||||
|
local existing="$1" desired="$2"
|
||||||
|
jq -n --argjson existing "$existing" --argjson desired "$desired" '
|
||||||
|
($existing // []) as $ex
|
||||||
|
| ($desired // []) as $des
|
||||||
|
| ($ex | map({(.name): .id}) | add // {}) as $idByName
|
||||||
|
| ($ex | map(select(.name == "id"))) as $idField
|
||||||
|
| $idField + ($des | map(
|
||||||
|
. as $f
|
||||||
|
| if ($idByName[$f.name] != null)
|
||||||
|
then $f + {id: $idByName[$f.name]}
|
||||||
|
else $f
|
||||||
|
end
|
||||||
|
))
|
||||||
|
'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Collection definitions (per SPEC.md) — filled in after bookcases/shelves
|
||||||
|
# ids are known, since relation fields need the target collectionId.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
AUTH_RULE='@request.auth.id != ""'
|
||||||
|
|
||||||
|
apply_collection() {
|
||||||
|
local name="$1" type="$2" desired_fields="$3" indexes_json="$4"
|
||||||
|
local existing existing_id existing_fields merged payload method path resp
|
||||||
|
|
||||||
|
existing="$(get_collection_json "$name")"
|
||||||
|
|
||||||
|
if [[ -z "$existing" ]]; then
|
||||||
|
info "Creating collection '$name'..."
|
||||||
|
method="POST"
|
||||||
|
path="/api/collections"
|
||||||
|
existing_fields="[]"
|
||||||
|
existing_id=""
|
||||||
|
else
|
||||||
|
info "Updating collection '$name'..."
|
||||||
|
method="PATCH"
|
||||||
|
path="/api/collections/$name"
|
||||||
|
existing_fields="$(printf '%s' "$existing" | jq -c '.fields')"
|
||||||
|
existing_id="$(printf '%s' "$existing" | jq -r '.id')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
merged="$(merge_fields "$existing_fields" "$desired_fields")"
|
||||||
|
|
||||||
|
payload="$(jq -n \
|
||||||
|
--arg name "$name" \
|
||||||
|
--arg type "$type" \
|
||||||
|
--argjson fields "$merged" \
|
||||||
|
--argjson indexes "$indexes_json" \
|
||||||
|
--arg rule "$AUTH_RULE" \
|
||||||
|
'{
|
||||||
|
name: $name,
|
||||||
|
type: $type,
|
||||||
|
fields: $fields,
|
||||||
|
indexes: $indexes,
|
||||||
|
listRule: $rule,
|
||||||
|
viewRule: $rule,
|
||||||
|
createRule: $rule,
|
||||||
|
updateRule: $rule,
|
||||||
|
deleteRule: $rule
|
||||||
|
}')"
|
||||||
|
|
||||||
|
resp="$(api_send "$method" "$path" "$payload")"
|
||||||
|
split_response AC "$resp"
|
||||||
|
if [[ "$AC_CODE" != "200" ]]; then
|
||||||
|
fail "Failed to save collection '$name' (HTTP $AC_CODE): $AC_BODY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ok "Collection '$name' ready (rules = auth-required)"
|
||||||
|
# Return the collection id via global for callers that need it.
|
||||||
|
COLLECTION_ID="$(printf '%s' "$AC_BODY" | jq -r '.id')"
|
||||||
|
|
||||||
|
# PocketBase auto-writes a pb_migrations/*.js snapshot per collection save,
|
||||||
|
# named "<unix_seconds>_<action>_<name>.js". Migrations with the same
|
||||||
|
# second-resolution timestamp are replayed in filename (alphabetical) order
|
||||||
|
# on a fresh instance, NOT creation order — so e.g. "created_books" can run
|
||||||
|
# before "created_shelves" even though shelves must exist first for the
|
||||||
|
# relation field. A 1s pause between collection saves keeps timestamps
|
||||||
|
# strictly increasing so a fresh instance replays them in the right order.
|
||||||
|
sleep 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1) bookcases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
BOOKCASES_FIELDS='[
|
||||||
|
{"name":"name","type":"text","required":true},
|
||||||
|
{"name":"note","type":"text","required":false},
|
||||||
|
{"name":"position","type":"number","required":false},
|
||||||
|
{"name":"deleted","type":"bool","required":false},
|
||||||
|
{"name":"created","type":"autodate","onCreate":true,"onUpdate":false},
|
||||||
|
{"name":"updated","type":"autodate","onCreate":true,"onUpdate":true}
|
||||||
|
]'
|
||||||
|
BOOKCASES_INDEXES='["CREATE INDEX `idx_bookcases_updated` ON `bookcases` (`updated`)"]'
|
||||||
|
|
||||||
|
apply_collection "bookcases" "base" "$BOOKCASES_FIELDS" "$BOOKCASES_INDEXES"
|
||||||
|
BOOKCASES_ID="$COLLECTION_ID"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2) shelves (relation -> bookcases)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SHELVES_FIELDS="$(jq -n --arg bcid "$BOOKCASES_ID" '[
|
||||||
|
{"name":"bookcase","type":"relation","required":true,"collectionId":$bcid,"maxSelect":1,"cascadeDelete":false},
|
||||||
|
{"name":"label","type":"text","required":true},
|
||||||
|
{"name":"position","type":"number","required":false},
|
||||||
|
{"name":"deleted","type":"bool","required":false},
|
||||||
|
{"name":"created","type":"autodate","onCreate":true,"onUpdate":false},
|
||||||
|
{"name":"updated","type":"autodate","onCreate":true,"onUpdate":true}
|
||||||
|
]')"
|
||||||
|
SHELVES_INDEXES='["CREATE INDEX `idx_shelves_updated` ON `shelves` (`updated`)"]'
|
||||||
|
|
||||||
|
apply_collection "shelves" "base" "$SHELVES_FIELDS" "$SHELVES_INDEXES"
|
||||||
|
SHELVES_ID="$COLLECTION_ID"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3) books (relation -> shelves, relation -> users, file cover)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
USERS_JSON="$(get_collection_json "users")"
|
||||||
|
[[ -n "$USERS_JSON" ]] || fail "The built-in 'users' collection was not found — is this a fresh/corrupt PocketBase data dir?"
|
||||||
|
USERS_ID="$(printf '%s' "$USERS_JSON" | jq -r '.id')"
|
||||||
|
|
||||||
|
BOOKS_FIELDS="$(jq -n --arg shid "$SHELVES_ID" --arg usid "$USERS_ID" '[
|
||||||
|
{"name":"title","type":"text","required":true},
|
||||||
|
{"name":"subtitle","type":"text","required":false},
|
||||||
|
{"name":"authors","type":"json","required":false},
|
||||||
|
{"name":"isbn13","type":"text","required":false},
|
||||||
|
{"name":"isbn10","type":"text","required":false},
|
||||||
|
{"name":"publisher","type":"text","required":false},
|
||||||
|
{"name":"published_date","type":"text","required":false},
|
||||||
|
{"name":"page_count","type":"number","required":false},
|
||||||
|
{"name":"description","type":"text","required":false},
|
||||||
|
{"name":"cover","type":"file","required":false,"maxSelect":1,
|
||||||
|
"mimeTypes":["image/jpeg","image/png","image/webp","image/gif"],
|
||||||
|
"thumbs":["100x150","300x450"]},
|
||||||
|
{"name":"cover_source_url","type":"text","required":false},
|
||||||
|
{"name":"shelf","type":"relation","required":false,"collectionId":$shid,"maxSelect":1,"cascadeDelete":false},
|
||||||
|
{"name":"notes","type":"text","required":false},
|
||||||
|
{"name":"added_by","type":"relation","required":false,"collectionId":$usid,"maxSelect":1,"cascadeDelete":false},
|
||||||
|
{"name":"deleted","type":"bool","required":false},
|
||||||
|
{"name":"created","type":"autodate","onCreate":true,"onUpdate":false},
|
||||||
|
{"name":"updated","type":"autodate","onCreate":true,"onUpdate":true}
|
||||||
|
]')"
|
||||||
|
BOOKS_INDEXES='[
|
||||||
|
"CREATE INDEX `idx_books_isbn13` ON `books` (`isbn13`)",
|
||||||
|
"CREATE INDEX `idx_books_updated` ON `books` (`updated`)"
|
||||||
|
]'
|
||||||
|
|
||||||
|
apply_collection "books" "base" "$BOOKS_FIELDS" "$BOOKS_INDEXES"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4) Lock down `users` — no self-registration, per SPEC.
|
||||||
|
# createRule = null (superuser-only), list/view = auth-required,
|
||||||
|
# update = only your own record, delete = null (superuser-only).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
info "Locking down 'users' collection (no self-registration)..."
|
||||||
|
USERS_PATCH_PAYLOAD='{
|
||||||
|
"listRule": "@request.auth.id != \"\"",
|
||||||
|
"viewRule": "@request.auth.id != \"\"",
|
||||||
|
"createRule": null,
|
||||||
|
"updateRule": "id = @request.auth.id",
|
||||||
|
"deleteRule": null
|
||||||
|
}'
|
||||||
|
resp="$(api_send "PATCH" "/api/collections/users" "$USERS_PATCH_PAYLOAD")"
|
||||||
|
split_response UP "$resp"
|
||||||
|
if [[ "$UP_CODE" != "200" ]]; then
|
||||||
|
fail "Failed to lock down 'users' collection (HTTP $UP_CODE): $UP_BODY"
|
||||||
|
fi
|
||||||
|
ok "'users' collection locked down (createRule=null, deleteRule=null)"
|
||||||
|
|
||||||
|
log ""
|
||||||
|
ok "Schema setup complete."
|
||||||
|
log ""
|
||||||
|
log "Next: create accounts with ./create-user.sh (there is no self-registration)."
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
You are implementing the SERVER half of the Bookshelf project.
|
||||||
|
READ FIRST: ~/bookshelf/docs/SPEC.md — it is authoritative. Follow it exactly.
|
||||||
|
|
||||||
|
A PocketBase v0.40.2 instance is ALREADY RUNNING at http://127.0.0.1:8090
|
||||||
|
(managed by `sprite-env services`; restart with `sprite-env services restart pocketbase`,
|
||||||
|
logs at /.sprite/logs/services/pocketbase.log). Superuser credentials are in
|
||||||
|
~/bookshelf/server/.dev-credentials. Binary + pb_data are in ~/bookshelf/server/.
|
||||||
|
|
||||||
|
Deliver, in ~/bookshelf/server/:
|
||||||
|
|
||||||
|
1. setup-schema.sh — idempotent bash (curl+jq only, portable to any Linux box).
|
||||||
|
Takes PB_URL, PB_EMAIL, PB_PASS from env or args. Creates/updates the three
|
||||||
|
collections exactly per SPEC, sets ALL API rules, creates the indexes, and
|
||||||
|
locks down the users collection (createRule=null). Safe to re-run. This is the
|
||||||
|
script the owner will run on their home server, so make its output clear and its
|
||||||
|
errors actionable.
|
||||||
|
2. Run it against the live instance and VERIFY, with real curl calls:
|
||||||
|
- anonymous GET of books/shelves/bookcases is REJECTED (expect 4xx) — this is the
|
||||||
|
single most important check in the whole task, do not hand-wave it
|
||||||
|
- anonymous user-registration attempt is REJECTED
|
||||||
|
- an authenticated user CAN do full CRUD on all three collections
|
||||||
|
- relations resolve; file upload to books.cover works (use a small generated PNG)
|
||||||
|
Print the actual status codes you observed. If any check fails, FIX IT and re-verify.
|
||||||
|
3. create-user.sh — superuser-driven account creation (the owner and their wife).
|
||||||
|
No self-registration path may exist.
|
||||||
|
4. deploy/ — with a README.md that a competent-but-not-expert person can follow:
|
||||||
|
- bookshelf.service (systemd, runs as non-root, Restart=always)
|
||||||
|
- Dockerfile + docker-compose.yml
|
||||||
|
- backup.sh (sqlite-safe backup of pb_data, plus how to restore)
|
||||||
|
- guidance on reaching a home server from outside: Tailscale (recommended)
|
||||||
|
vs port-forward + Caddy/TLS. Be concrete about the tradeoffs. HTTPS is
|
||||||
|
required because the app sends passwords.
|
||||||
|
5. Confirm pb_migrations/ captured the schema so a fresh instance self-provisions.
|
||||||
|
Test this: point a throwaway instance at a NEW empty data dir with the same
|
||||||
|
migrations, start it, confirm collections appear, then delete the throwaway.
|
||||||
|
6. server/README.md — quickstart.
|
||||||
|
|
||||||
|
Do not modify ~/bookshelf/app or ~/bookshelf/docs.
|
||||||
|
Keep ~/bookshelf/server/.dev-credentials out of version control (write .gitignore).
|
||||||
|
Finish with a <=25 line report: what you built, the verification status codes, and
|
||||||
|
anything you had to deviate from in the SPEC and why.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
You are creating the Android project SCAFFOLD + DESIGN SYSTEM for Bookshelf.
|
||||||
|
READ FIRST: ~/bookshelf/docs/SPEC.md — authoritative, follow exactly.
|
||||||
|
|
||||||
|
Environment (already installed, do not reinstall):
|
||||||
|
JAVA_HOME=~/toolchain/jdk21 (JDK 21) ANDROID_HOME=~/toolchain/android-sdk
|
||||||
|
Installed: platforms;android-37.0, build-tools;37.0.0, platform-tools
|
||||||
|
No emulator/KVM available — you CANNOT run the app. Verify by compiling.
|
||||||
|
|
||||||
|
Create a Gradle project at ~/bookshelf/app. Deliver:
|
||||||
|
|
||||||
|
1. Working Gradle build: settings.gradle.kts, build.gradle.kts, gradle.properties,
|
||||||
|
gradle/libs.versions.toml (version catalog), app/build.gradle.kts, the Gradle
|
||||||
|
wrapper (download a wrapper JAR compatible with AGP for compileSdk 37 + JDK 21),
|
||||||
|
local.properties pointing at the SDK, and a sensible .gitignore.
|
||||||
|
Pick versions that ACTUALLY RESOLVE — check Maven Central / dl.google.com rather
|
||||||
|
than guessing. Declare every library from the SPEC in the catalog now, even ones
|
||||||
|
later waves will use, so downstream workers never touch build files.
|
||||||
|
2. AndroidManifest with the permissions the SPEC implies (camera, internet) and a
|
||||||
|
single MainActivity hosting Compose.
|
||||||
|
3. ui/theme: Color.kt, Type.kt, Theme.kt implementing the SPEC palette for BOTH
|
||||||
|
light and dark. Dynamic color OFF. Bundle the Literata variable/static TTF in
|
||||||
|
res/font (download from the Google Fonts GitHub repo, it is OFL — include the
|
||||||
|
license file). Full Material3 ColorScheme for both modes, mapped thoughtfully:
|
||||||
|
this app must read as warm paper and mahogany, never as default-Material purple.
|
||||||
|
4. A small reusable component set in ui/theme or ui/components that later waves will
|
||||||
|
build every screen from — at minimum: BookshelfScaffold (topbar w/ serif title +
|
||||||
|
gold hairline rule), PaperSurface, GoldDivider, BookCover (Coil, correct 2:3
|
||||||
|
aspect, letterpress-ish placeholder when no cover, graceful error state),
|
||||||
|
PrimaryButton/SecondaryButton, EmptyState, SyncStatusBar. Make these genuinely
|
||||||
|
nice — the owner explicitly cares that this "feels like books". Restrained and
|
||||||
|
typographic beats skeuomorphic.
|
||||||
|
5. Paparazzi configured so Compose renders to PNG on the JVM (no device). Add
|
||||||
|
screenshot tests for the component set in BOTH light and dark, run
|
||||||
|
`./gradlew recordPaparazziDebug` (or the equivalent task), and confirm real PNGs
|
||||||
|
land on disk. If Paparazzi will not cooperate with this AGP/Compose combination
|
||||||
|
after a genuine effort, say so plainly in your report and fall back to Robolectric
|
||||||
|
+ Roborazzi; do not silently skip visual verification.
|
||||||
|
6. A placeholder MainActivity screen that renders the component set, so wave 3 has a
|
||||||
|
living style reference.
|
||||||
|
|
||||||
|
MUST end green: `./gradlew assembleDebug` and `./gradlew test` both pass.
|
||||||
|
Iterate until they do — a broken build blocks every downstream worker.
|
||||||
|
|
||||||
|
Write ~/bookshelf/app/BUILD_NOTES.md recording the EXACT resolved versions (AGP,
|
||||||
|
Gradle, Kotlin, KSP, Compose BOM, Room, Retrofit, Coil, CameraX, ML Kit, WorkManager,
|
||||||
|
Paparazzi) and any compatibility traps you hit. Downstream workers depend on this.
|
||||||
|
|
||||||
|
Do not modify ~/bookshelf/server or ~/bookshelf/docs.
|
||||||
|
Finish with a <=25 line report: versions, what builds, Paparazzi status, deviations.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# run-task.sh <task-name> <prompt-file> [cwd]
|
||||||
|
# Quota-aware worker runner: survives 5-hour usage-limit caps by sleeping until
|
||||||
|
# quota refreshes and resuming the SAME session instead of restarting from zero.
|
||||||
|
set -u
|
||||||
|
NAME="$1"; PROMPT_FILE="$2"; CWD="${3:-$HOME/bookshelf}"
|
||||||
|
L="$HOME/bookshelf/logs"; mkdir -p "$L"
|
||||||
|
LOG="$L/${NAME}.json"; ERR="$L/${NAME}.err"; SIDF="$L/${NAME}.sid"; ST="$L/${NAME}.state"
|
||||||
|
|
||||||
|
MAX_WALL="${MAX_WALL:-86400}" # 24h total patience
|
||||||
|
POLL="${POLL:-600}" # 10 min between quota probes
|
||||||
|
MAX_HARD_FAILS="${MAX_HARD_FAILS:-3}"
|
||||||
|
|
||||||
|
export JAVA_HOME="$HOME/toolchain/jdk21"
|
||||||
|
export ANDROID_HOME="$HOME/toolchain/android-sdk"
|
||||||
|
export ANDROID_SDK_ROOT="$ANDROID_HOME"
|
||||||
|
export PATH="$JAVA_HOME/bin:$ANDROID_HOME/platform-tools:$PATH"
|
||||||
|
export GRADLE_USER_HOME="$HOME/.gradle"
|
||||||
|
cd "$CWD" || exit 1
|
||||||
|
|
||||||
|
# Stable session id so a killed run can be resumed rather than restarted.
|
||||||
|
if [ ! -s "$SIDF" ]; then
|
||||||
|
python3 -c "import uuid;print(uuid.uuid4())" > "$SIDF"
|
||||||
|
FRESH=1
|
||||||
|
else
|
||||||
|
FRESH=0 # sid pre-seeded (recovered) or left by an earlier attempt
|
||||||
|
fi
|
||||||
|
SID="$(cat "$SIDF")"
|
||||||
|
|
||||||
|
say(){ echo "[$(date -Is)] $NAME: $*" >> "$ST"; }
|
||||||
|
say "start sid=$SID fresh=$FRESH wall=${MAX_WALL}s poll=${POLL}s"
|
||||||
|
|
||||||
|
CONT_PROMPT="Continue the task you were working on, from wherever you left off. \
|
||||||
|
Your original instructions are earlier in this conversation; re-read them and any \
|
||||||
|
files you already wrote before doing more work. Do not restart from scratch and do \
|
||||||
|
not redo completed work. Finish the task and give the final report."
|
||||||
|
|
||||||
|
deadline=$(( $(date +%s) + MAX_WALL ))
|
||||||
|
attempt=0; hard=0; quota_waits=0
|
||||||
|
|
||||||
|
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||||
|
attempt=$((attempt+1))
|
||||||
|
if [ "$attempt" -eq 1 ] && [ "$FRESH" -eq 1 ]; then
|
||||||
|
say "attempt $attempt: fresh (--session-id)"
|
||||||
|
claude -p --model sonnet --permission-mode bypassPermissions \
|
||||||
|
--output-format json --add-dir "$HOME/bookshelf" \
|
||||||
|
--session-id "$SID" < "$PROMPT_FILE" > "$LOG" 2>"$ERR"
|
||||||
|
else
|
||||||
|
say "attempt $attempt: resume $SID"
|
||||||
|
printf '%s' "$CONT_PROMPT" | claude -p --model sonnet \
|
||||||
|
--permission-mode bypassPermissions --output-format json \
|
||||||
|
--add-dir "$HOME/bookshelf" --resume "$SID" > "$LOG" 2>"$ERR"
|
||||||
|
fi
|
||||||
|
rc=$?
|
||||||
|
|
||||||
|
blob="$(cat "$LOG" "$ERR" 2>/dev/null | head -c 20000)"
|
||||||
|
|
||||||
|
# 1) quota / rate limit -> wait it out, do NOT burn a hard-fail
|
||||||
|
if printf '%s' "$blob" | grep -qiE 'usage limit|limit will reset|limit resets|rate_limit_error|rate limit exceeded|429|too many requests|overloaded_error'; then
|
||||||
|
quota_waits=$((quota_waits+1))
|
||||||
|
say "QUOTA hit (wait #$quota_waits). sleeping ${POLL}s then probing again."
|
||||||
|
sleep "$POLL"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2) session vanished -> start clean once
|
||||||
|
if printf '%s' "$blob" | grep -qiE 'no conversation found|session not found|could not resume'; then
|
||||||
|
say "session $SID unresumable; starting fresh"
|
||||||
|
python3 -c "import uuid;print(uuid.uuid4())" > "$SIDF"; SID="$(cat "$SIDF")"; FRESH=1; attempt=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3) success
|
||||||
|
isErr="$(jq -r '.is_error // false' "$LOG" 2>/dev/null)"
|
||||||
|
if [ "$rc" -eq 0 ] && [ "$isErr" != "true" ]; then
|
||||||
|
say "SUCCESS after $attempt attempt(s), $quota_waits quota wait(s)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4) genuine failure
|
||||||
|
hard=$((hard+1))
|
||||||
|
say "hard failure #$hard (rc=$rc is_error=$isErr)"
|
||||||
|
if [ "$hard" -ge "$MAX_HARD_FAILS" ]; then say "GIVING UP after $hard hard failures"; break; fi
|
||||||
|
sleep 60
|
||||||
|
done
|
||||||
|
|
||||||
|
[ "$(date +%s)" -ge "$deadline" ] && say "WALL CLOCK EXCEEDED"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "=== $NAME attempts=$attempt quota_waits=$quota_waits hard_fails=$hard ==="
|
||||||
|
jq -r '"cost=$" + ((.total_cost_usd//0)|tostring) + " turns=" + ((.num_turns//0)|tostring) + " err=" + ((.is_error//"?")|tostring)' "$LOG" 2>/dev/null
|
||||||
|
echo "--- result (tail) ---"
|
||||||
|
jq -r '.result // "no result"' "$LOG" 2>/dev/null | tail -c 1800
|
||||||
|
} > "$L/${NAME}.summary"
|
||||||