6 Commits
Author SHA1 Message Date
gregor 2fab51878f 0.0.9
Publish npm package / publish (push) Successful in 26s
version bump
2026-10-08 09:42:06 +02:00
gregor 8e3abca9d7 some cleanup 2026-10-07 15:35:04 +02:00
gregor c7e7e615c2 macos update
Publish npm package / publish (push) Canceled after 0s
2026-10-07 14:02:19 +02:00
gregor 7fb87fbb96 run on ios 2026-10-07 13:00:58 +02:00
gregor aa647ee869 fix imports, make emulator run on system architecture 2026-10-07 12:55:26 +02:00
gregorandClaude Opus 4.8 97bda53e9b docs: add mobile/Expo frontend, drop Tailwind/TanStack mention
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:36:29 +02:00
45 changed files with 821 additions and 387 deletions
+7 -3
View File
@@ -7,7 +7,7 @@ on:
jobs: jobs:
publish: publish:
runs-on: x86 runs-on: local
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -21,6 +21,10 @@ jobs:
node-version: 24 node-version: 24
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
# Stage-only tokens require `npm stage publish` (npm >= 11.15.0).
- name: Update npm
run: npm install -g npm@^11.15.0
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
@@ -33,7 +37,7 @@ jobs:
- name: Build - name: Build
run: bun run build run: bun run build
- name: Publish - name: Stage publish
run: npm publish --access public run: npm stage publish --access public
env: env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+52 -5
View File
@@ -1,11 +1,16 @@
# create-glstack # create-glstack
Scaffold a fullstack application with a Go backend, PostgreSQL database, and your choice of frontend framework, connected via Connect RPC. Scaffold a fullstack application with a Go backend, PostgreSQL database, and your choice of web and mobile frontends, connected via Connect RPC.
Supported frontends: Supported web frontends:
- [SvelteKit](https://kit.svelte.dev/) - [SvelteKit](https://kit.svelte.dev/)
- [Solid Start](https://start.solidjs.com/) - [Solid Start](https://start.solidjs.com/)
- [Nuxt](https://nuxt.com/)
Supported mobile frontends:
- [React Native + Expo](https://expo.dev/)
## Usage ## Usage
@@ -13,18 +18,29 @@ Supported frontends:
bun create glstack bun create glstack
``` ```
The CLI asks for:
- **Project name** -- lowercase letters, digits and dashes; used for the directory, the database and the `@<name>/rpc` package. An existing non-empty directory is never overwritten
- **Web frontend** -- SvelteKit, Solid Start, Nuxt or none
- **Mobile frontend** -- React Native + Expo or none
- **Bundle identifier** (Expo only) -- reverse-DNS id used as both the iOS bundle identifier and the Android package, e.g. `com.example.app`
- **Go package prefix** -- e.g. `github.com/you`, giving the module path `github.com/you/<name>`
- **Install dependencies** -- runs `bun install` in the new project
## What you get ## What you get
A monorepo with the following structure: A monorepo with the following structure:
- **`services/api`** -- Go backend using [Cobra](https://github.com/spf13/cobra) for CLI, [pgx](https://github.com/jackc/pgx) for Postgres, and [Connect RPC](https://connectrpc.com/) for the API layer - **`services/api`** -- Go backend using [Cobra](https://github.com/spf13/cobra) for CLI, [pgx](https://github.com/jackc/pgx) for Postgres, and [Connect RPC](https://connectrpc.com/) for the API layer
- **`apps/web`** -- Frontend app (SvelteKit or Solid Start) with TailwindCSS and [TanStack Query](https://tanstack.com/query) - **`apps/web`** -- Web frontend app (SvelteKit, Solid Start or Nuxt)
- **`apps/mobile`** -- Optional [React Native + Expo](https://expo.dev/) mobile app sharing the same RPC client
- **`packages/proto`** -- Protobuf service definitions with [Buf](https://buf.build/) for codegen (Go + TypeScript) - **`packages/proto`** -- Protobuf service definitions with [Buf](https://buf.build/) for codegen (Go + TypeScript)
- **`packages/rpc`** -- Generated TypeScript Connect RPC client shared across frontend apps - **`packages/rpc`** -- Generated TypeScript Connect RPC client shared across frontend apps
- **`scripts`** -- Codegen helpers, e.g. building the `packages/rpc` index from the generated client
### Dev environment ### Dev environment
The template includes a [devenv.nix](https://devenv.sh/) configuration that sets up: The template includes a [devenv.nix](https://devenv.sh/) configuration for macOS and Linux that sets up:
- Go, TypeScript, Bun, Node.js - Go, TypeScript, Bun, Node.js
- PostgreSQL with an auto-provisioned database - PostgreSQL with an auto-provisioned database
@@ -32,10 +48,41 @@ The template includes a [devenv.nix](https://devenv.sh/) configuration that sets
- [Air](https://github.com/air-verse/air) for Go live reload - [Air](https://github.com/air-verse/air) for Go live reload
- [sqlc](https://sqlc.dev/) for type-safe SQL - [sqlc](https://sqlc.dev/) for type-safe SQL
- [dbmate](https://github.com/amacneil/dbmate) for database migrations - [dbmate](https://github.com/amacneil/dbmate) for database migrations
- With Expo: the Android SDK and an emulator, plus the iOS simulator on macOS
Start everything with:
```sh
cd <name>
devenv up
```
### Mobile (Expo)
`devenv up` starts Metro as its own process, creates an Android emulator matching your CPU (arm64 or x86_64), waits up to 5 minutes for it to boot and runs the app on it. On macOS it also builds and runs the app on the iOS simulator. Both apps use the same Metro bundler, so saving a file reloads both, and one platform failing to build doesn't stop the other.
The app reaches the API at `127.0.0.1:8080` everywhere: the iOS simulator shares the Mac's network, and `devenv up` runs `adb reverse` for the emulator and any Android phone connected over USB. Physical iPhones aren't covered, since the API only listens on localhost.
The example apps poll the API every 2 seconds, so a change made on one device or in the web app shows up on the others.
The native `ios/` and `android/` folders are generated from `app.json` and not committed. After changing native config or upgrading Expo, delete them and they are regenerated on the next run. The same applies when a native build can't find files under `node_modules`: the folders hold absolute paths into `node_modules` from when they were generated.
#### iOS on macOS
Nix can't provide Xcode, so the iOS simulator needs a few things installed on the Mac. Entering the devenv shell checks them and lists anything missing (once they pass, at most once a day); run `ios-check` to check again. Until they are met, `devenv up` skips iOS and runs everything else. Set `SKIP_IOS_CHECK=1` to turn the check off, e.g. in `devenv.local.nix`.
| Requirement | How to fix |
| --- | --- |
| Xcode | Install it from the [App Store](https://apps.apple.com/app/xcode/id497799835) |
| Xcode license and first-launch setup | `sudo xcodebuild -runFirstLaunch` |
| An iOS simulator runtime | `xcodebuild -downloadPlatform iOS` |
| An iPhone simulator | Create one in Xcode › Window › Devices and Simulators |
CocoaPods comes from Nix. The iOS build uses `/Applications/Xcode.app` (or the Xcode chosen with `xcode-select`) outside the Nix compiler environment, so the Command Line Tools being selected is fine.
### Starter example ### Starter example
The generated project includes a working Todo CRUD example wired end-to-end: protobuf schema, Go service implementation, SQL queries, and frontend UI. The generated project includes a working Todo CRUD example wired end-to-end: protobuf schema, Go service implementation, SQL queries, and frontend UI. When a todo is checked off and the list re-sorts, rows animate into place: with [Reanimated](https://docs.swmansion.com/react-native-reanimated/) in the mobile app and the [GSAP](https://gsap.com/) Flip plugin in the web apps.
## License ## License
+2 -2
View File
@@ -6,7 +6,7 @@
"name": "glstack", "name": "glstack",
"dependencies": { "dependencies": {
"@clack/prompts": "^1.2.0", "@clack/prompts": "^1.2.0",
"@gregorlohaus/tdir": "^0.2.0", "@gregorlohaus/tdir": "^0.3.0",
"zod": "^4.3.6", "zod": "^4.3.6",
}, },
"devDependencies": { "devDependencies": {
@@ -22,7 +22,7 @@
"@clack/prompts": ["@clack/prompts@1.2.0", "", { "dependencies": { "@clack/core": "1.2.0", "fast-string-width": "^1.1.0", "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w=="], "@clack/prompts": ["@clack/prompts@1.2.0", "", { "dependencies": { "@clack/core": "1.2.0", "fast-string-width": "^1.1.0", "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w=="],
"@gregorlohaus/tdir": ["@gregorlohaus/tdir@0.2.0", "", { "peerDependencies": { "zod": "^4" }, "bin": { "tdir": "dist/cli.js" } }, "sha512-LwISQC7iARezu7SQZJk9TV8Sqp8I/fVMssH18lWL1KD/ETpWp2LwwTzm/j9aWcs+Eo5OBQctbqFTafo9Nfue+A=="], "@gregorlohaus/tdir": ["@gregorlohaus/tdir@0.3.0", "", { "peerDependencies": { "zod": "^4" }, "bin": { "tdir": "dist/cli.js" } }, "sha512-b6l+yXdyFpPI573iuMCdVHkJjD3j+fN7LRHnHzorU0QGRh6OVrOZsiiqMA5/9v+RiAF5SDBrZckj8zp5WoqLtA=="],
"@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
+57 -6
View File
@@ -1,20 +1,34 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import * as p from "@clack/prompts"; import * as p from "@clack/prompts";
import { initRenderer } from '@gregorlohaus/tdir' import { initRenderer, TargetExistsError } from '@gregorlohaus/tdir'
import { z } from 'zod' import { z } from 'zod'
import path from "node:path"; import path from "node:path";
import { existsSync, readdirSync, statSync } from "node:fs";
p.intro(`create-glstack ${process.env.GLSTACK_DEV && 'isDev'}`); p.intro(`create-glstack${process.env.GLSTACK_DEV === 'true' ? ' (dev)' : ''}`);
const defaultName = "glstack-test"
// Java and Kotlin keywords can't be Android package segments.
const reservedWords = new Set(["abstract", "as", "assert", "boolean", "break", "byte", "case", "catch", "char", "class", "const", "continue", "default", "do", "double", "else", "enum", "extends", "false", "final", "finally", "float", "for", "fun", "goto", "if", "implements", "import", "in", "instanceof", "int", "interface", "is", "long", "native", "new", "null", "object", "package", "private", "protected", "public", "return", "short", "static", "strictfp", "super", "switch", "synchronized", "this", "throw", "throws", "transient", "true", "try", "typealias", "typeof", "val", "var", "void", "volatile", "when", "while"])
const isNonEmptyPath = (target: string) =>
existsSync(target) && (!statSync(target).isDirectory() || readdirSync(target).length > 0)
const project = await p.group( const project = await p.group(
{ {
name: () => name: () =>
p.text({ p.text({
message: "What would you like to name your project?", message: "What would you like to name your project?",
placeholder: "glstack-test", placeholder: defaultName,
defaultValue: "glstack-test", defaultValue: defaultName,
validate: (value) => { validate: (value) => {
const name = value || defaultName
// Used as directory, npm scope, Go module path segment and database name.
if (!/^[a-z][a-z0-9-]*$/.test(name)) {
return "Use lowercase letters, digits and dashes, starting with a letter"
}
if (isNonEmptyPath(name)) return `./${name} already exists and is not empty`
return undefined return undefined
}, },
}), }),
@@ -24,6 +38,7 @@ const project = await p.group(
options: [ options: [
{ value: "svelte-kit", label: "SvelteKit" }, { value: "svelte-kit", label: "SvelteKit" },
{ value: "solid-start", label: "SolidStart" }, { value: "solid-start", label: "SolidStart" },
{ value: "nuxt", label: "Nuxt" },
{ value: "none", label: "None" } { value: "none", label: "None" }
] ]
}) })
@@ -37,12 +52,35 @@ const project = await p.group(
] ]
}) })
, ,
bundleid: async ({ results }) => {
if (results.mobile !== "expo") return ""
// Valid as both an iOS bundle identifier and an Android package name.
const nameSegment = String(results.name).replace(/[^a-z0-9]/g, "")
const fallback = `com.${reservedWords.has(nameSegment) ? `${nameSegment}app` : nameSegment}.mobile`
return await p.text({
message: "What bundle identifier should the mobile app use?",
placeholder: fallback,
defaultValue: fallback,
validate: (value) => {
if (!value) return undefined
if (!/^[a-zA-Z][a-zA-Z0-9]*(\.[a-zA-Z][a-zA-Z0-9]*)+$/.test(value)) {
return "Use reverse-DNS segments of letters and digits, e.g. com.example.app"
}
const reserved = value.split(".").find((segment) => reservedWords.has(segment))
if (reserved) return `"${reserved}" is a Java/Kotlin keyword and can't be part of an Android package`
return undefined
},
})
},
goprefix: () => goprefix: () =>
p.text({ p.text({
message: "What would you like to use as a go package prefix?", message: "What would you like to use as a go package prefix?",
placeholder: "github.com/glstack-test", placeholder: "github.com/glstack-test",
defaultValue: "github.com/glstack-test", defaultValue: "github.com/glstack-test",
validate: (value) => { validate: (value) => {
if (value && !/^[a-zA-Z0-9._~-]+(\/[a-zA-Z0-9._~-]+)*$/.test(value)) {
return "Use a module path without scheme or trailing slash, e.g. github.com/you"
}
return undefined return undefined
}, },
}), }),
@@ -65,11 +103,24 @@ const render = createRenderer(z.object({
name: z.string(), name: z.string(),
goprefix: z.string(), goprefix: z.string(),
frontend: z.string(), frontend: z.string(),
mobile: z.string() mobile: z.string(),
bundleid: z.string()
}) })
})) }))
const destDir = path.join("./", project.name); const destDir = path.join("./", project.name);
render(destDir, { project }, { reverseMap: true }) try {
// `results` in the bundleid prompt makes group() infer it as unknown.
render(destDir, { project: { ...project, bundleid: project.bundleid as string } }, {
reverseMap: true,
overwrite: false,
// npm never publishes .gitignore/.npmrc, so the template stores them as _gitignore/_npmrc.
rename: { _gitignore: ".gitignore", _npmrc: ".npmrc" },
})
} catch (error) {
if (!(error instanceof TargetExistsError)) throw error
p.cancel(`./${project.name} already exists and is not empty.`)
process.exit(1)
}
const s = p.spinner(); const s = p.spinner();
if (project.installDeps) { if (project.installDeps) {
+4 -7
View File
@@ -1,15 +1,12 @@
{ {
"name": "create-glstack", "name": "create-glstack",
"version": "0.0.8", "version": "0.0.9",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"create-glstack": "./dist/index.js" "create-glstack": "dist/index.js"
}, },
"files": [ "files": ["dist", "template"],
"dist",
"template"
],
"scripts": { "scripts": {
"build": "bun build ./index.ts --outdir ./dist --target bun", "build": "bun build ./index.ts --outdir ./dist --target bun",
"prepublishOnly": "bun run build" "prepublishOnly": "bun run build"
@@ -22,7 +19,7 @@
}, },
"dependencies": { "dependencies": {
"@clack/prompts": "^1.2.0", "@clack/prompts": "^1.2.0",
"@gregorlohaus/tdir": "^0.2.0", "@gregorlohaus/tdir": "^0.3.0",
"zod": "^4.3.6" "zod": "^4.3.6"
} }
} }
@@ -10,3 +10,6 @@ devenv.local.yaml
.pre-commit-config.yaml .pre-commit-config.yaml
node_modules node_modules
scripts/node_modules scripts/node_modules
# Android emulator state
.android
@@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { gsap } from "gsap"
import { Flip } from "gsap/Flip"
import { createRouter, type Todo } from "@<@var(context.project.name)>/rpc"
gsap.registerPlugin(Flip)
const router = createRouter("http://127.0.0.1:8080")
// Changes made elsewhere (another tab, the mobile app) show up within this interval.
const syncIntervalMs = 2000
const todos = ref<Todo[]>([])
const offline = ref(false)
const todoToCreateTask = ref("")
// Like the mobile app: rows spring into their new place when the list re-sorts, new rows fade in.
const showTodos = async (next: Todo[]) => {
const rows = Flip.getState(".todo-row")
todos.value = next
await nextTick()
Flip.from(rows, {
targets: ".todo-row",
duration: 0.5,
ease: "back.out(1.4)",
onEnter: (entering) => gsap.fromTo(entering, { opacity: 0 }, { opacity: 1, duration: 0.3 }),
})
}
const fetchTodos = () => {
router.todos.listTodos({}).then((r) => {
offline.value = false
// Polling returns the same list most of the time; skip re-rendering it.
if (JSON.stringify(todos.value) !== JSON.stringify(r.todos)) showTodos(r.todos)
}).catch(() => offline.value = true)
}
let interval: ReturnType<typeof setInterval> | undefined
onMounted(() => {
fetchTodos()
interval = setInterval(fetchTodos, syncIntervalMs)
})
onUnmounted(() => clearInterval(interval))
const setTodoDone = (todo: Todo, event: Event) => {
const done = (event.target as HTMLInputElement).checked
router.todos.updateTodo({ todo: { id: todo.id, task: todo.task, done } }).then(fetchTodos, () => offline.value = true)
}
const deleteTodo = async (todo: Todo, event: Event) => {
await gsap.to((event.currentTarget as HTMLElement).closest(".todo-row"), { opacity: 0, duration: 0.3 })
router.todos.deleteTodo({ todo: { id: todo.id } }).then(fetchTodos, () => offline.value = true)
}
const createTodo = () => {
const task = todoToCreateTask.value.trim()
if (!task) return
router.todos.createTodo({ todo: { id: crypto.randomUUID(), task } }).then(() => {
todoToCreateTask.value = ""
fetchTodos()
}, () => offline.value = true)
}
</script>
<template>
<main>
<h1>Create Todo</h1>
<p v-if="offline" class="offline">Can't reach the API, retrying…</p>
<form @submit.prevent="createTodo">
<input v-model="todoToCreateTask" type="text" placeholder="What needs to be done?">
<button>create Todo</button>
</form>
<div v-for="todo in todos" :key="todo.id" class="todo-row" :data-flip-id="todo.id">
<span>{{ todo.task }}</span>
<input type="checkbox" :checked="todo.done" @change="setTodoDone(todo, $event)">
<button @click="deleteTodo(todo, $event)">delete</button>
</div>
</main>
</template>
<style>
main { max-width: 32rem; margin: 2rem auto; font-family: system-ui, sans-serif; }
form, .todo-row { display: flex; gap: 0.75rem; align-items: center; margin-bottom: 0.75rem; }
input[type="text"] { flex: 1; padding: 0.5rem; border: 1px solid #8e8e93; border-radius: 8px; }
.todo-row span { flex: 1; }
.offline { color: #d32f2f; }
</style>
@@ -0,0 +1,7 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
// Same port as the other web templates, which the API allows via CORS.
devServer: { port: 5173 },
})
@@ -0,0 +1,24 @@
{
"name": "web",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"check": "nuxt typecheck"
},
"dependencies": {
"@<@var(context.project.name)>/rpc": "workspace:*",
"gsap": "^3.15.0",
"nuxt": "^4.6.0",
"vue": "^3.5.43",
"vue-router": "^5.3.1"
},
"devDependencies": {
"typescript": "^6.0.3",
"vue-tsc": "^3.3.12"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,2 @@
User-Agent: *
Disallow:
@@ -0,0 +1,18 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}
@@ -1,21 +1,25 @@
{ {
"name": "example-bare", "name": "web",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"start": "vite start", "start": "node .output/server/index.mjs",
"preview": "vite preview" "preview": "vite preview",
"check": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@<@var(context.project.name)>/rpc": "workspace:*", "@<@var(context.project.name)>/rpc": "workspace:*",
"@solidjs/start": "2.0.0-alpha.2", "@solidjs/start": "^2.0.0",
"@solidjs/vite-plugin-nitro-2": "^0.1.0", "solid-js": "^1.9.14",
"solid-js": "^1.9.5", "vite": "^8.0.0",
"vite": "^7.0.0" "nitro": "^3.0.260610-beta",
"gsap": "^3.15.0"
}, },
"engines": { "engines": {
"node": ">=22" "node": ">=24"
}, },
"devDependencies": {} "devDependencies": {
"typescript": "^6.0.3"
}
} }
@@ -1,61 +1,5 @@
body { main { max-width: 32rem; margin: 2rem auto; font-family: system-ui, sans-serif; }
font-family: Gordita, Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; form, .todo-row { display: flex; gap: 0.75rem; align-items: center; margin-bottom: 0.75rem; }
} input[type="text"] { flex: 1; padding: 0.5rem; border: 1px solid #8e8e93; border-radius: 8px; }
.todo-row span { flex: 1; }
a { .offline { color: #d32f2f; }
margin-right: 1rem;
}
main {
text-align: center;
padding: 1em;
margin: 0 auto;
}
h1 {
color: #335d92;
text-transform: uppercase;
font-size: 4rem;
font-weight: 100;
line-height: 1.1;
margin: 4rem auto;
max-width: 14rem;
}
p {
max-width: 14rem;
margin: 2rem auto;
line-height: 1.35;
}
@media (min-width: 480px) {
h1 {
max-width: none;
}
p {
max-width: none;
}
}
.increment {
font-family: inherit;
font-size: inherit;
padding: 1em 2em;
color: #335d92;
background-color: rgba(68, 107, 158, 0.1);
border-radius: 2em;
border: 2px solid rgba(68, 107, 158, 0);
outline: none;
width: 200px;
font-variant-numeric: tabular-nums;
cursor: pointer;
}
.increment:focus {
border: 2px solid #335d92;
}
.increment:active {
background-color: rgba(68, 107, 158, 0.2);
}
@@ -1,58 +1,84 @@
import { createSignal, For } from "solid-js"; import { createSignal, For, onCleanup, onMount, Show } from "solid-js";
import { gsap } from "gsap";
import { Flip } from "gsap/Flip";
import { createRouter, Todo } from "@<@var(context.project.name)>/rpc" import { createRouter, Todo } from "@<@var(context.project.name)>/rpc"
import { createEffect } from 'solid-js'
import "./app.css"; import "./app.css";
export default function App() { gsap.registerPlugin(Flip)
const router = createRouter("http://127.0.0.1:8080") const router = createRouter("http://127.0.0.1:8080")
// Changes made elsewhere (another tab, the mobile app) show up within this interval.
const syncIntervalMs = 2000
export default function App() {
const [todos, setTodos] = createSignal<Todo[]>([]); const [todos, setTodos] = createSignal<Todo[]>([]);
const [offline, setOffline] = createSignal(false);
const [todoToCreateTask, setTodoToCreateTask] = createSignal<string>(""); const [todoToCreateTask, setTodoToCreateTask] = createSignal<string>("");
const fetchTodos = () => {
router.todos.listTodos({}).then((r) => { // Like the mobile app: rows spring into their new place when the list re-sorts, new rows fade in.
setTodos(r.todos) // Solid recreates rows for new objects; data-flip-id lets Flip match them to the old ones.
const showTodos = (next: Todo[]) => {
const rows = Flip.getState(".todo-row")
setTodos(next)
Flip.from(rows, {
targets: ".todo-row",
duration: 0.5,
ease: "back.out(1.4)",
onEnter: (entering) => gsap.fromTo(entering, { opacity: 0 }, { opacity: 1, duration: 0.3 }),
}) })
} }
createEffect(() => {
const fetchTodos = () => {
router.todos.listTodos({}).then((r) => {
setOffline(false)
// Polling returns the same list most of the time; skip re-rendering it.
if (JSON.stringify(todos()) !== JSON.stringify(r.todos)) showTodos(r.todos)
}).catch(() => setOffline(true))
}
onMount(() => {
fetchTodos() fetchTodos()
const interval = setInterval(fetchTodos, syncIntervalMs)
onCleanup(() => clearInterval(interval))
}) })
const setTodoDone = (id: string, task: string) => { const setTodoDone = (id: string, task: string) => {
return async (event : Event & { currentTarget: HTMLInputElement }) => { return (event: Event & { currentTarget: HTMLInputElement }) => {
await router.todos.updateTodo({ todo: { id, task, done: event.currentTarget.checked } }) router.todos.updateTodo({ todo: { id, task, done: event.currentTarget.checked } }).then(fetchTodos, () => setOffline(true))
fetchTodos()
} }
} }
const deleteTodo = (id: string) => { const deleteTodo = (id: string) => {
return async () => { return async (event: Event & { currentTarget: HTMLElement }) => {
await router.todos.deleteTodo({ todo: { id } }) await gsap.to(event.currentTarget.closest(".todo-row"), { opacity: 0, duration: 0.3 })
fetchTodos() router.todos.deleteTodo({ todo: { id } }).then(fetchTodos, () => setOffline(true))
} }
} }
const createTodo = () => { const createTodo = () => {
router.todos.createTodo({ todo: { id: crypto.randomUUID(), task: todoToCreateTask() } }).then((r) => { const task = todoToCreateTask().trim()
console.log(r) if (!task) return
router.todos.createTodo({ todo: { id: crypto.randomUUID(), task } }).then(() => {
setTodoToCreateTask("")
fetchTodos() fetchTodos()
}).catch((e) => { }, () => setOffline(true))
console.log(e)
})
} }
return ( return (
<div> <main>
<p>Create Todo</p> <h1>Create Todo</h1>
<input value={todoToCreateTask()} onInput={(e) => setTodoToCreateTask(e.currentTarget.value)} type="text"/> <Show when={offline()}><p class="offline">Can't reach the API, retrying…</p></Show>
<button onclick={createTodo}> create </button> <form onSubmit={(event) => { event.preventDefault(); createTodo() }}>
<input value={todoToCreateTask()} onInput={(e) => setTodoToCreateTask(e.currentTarget.value)} type="text" placeholder="What needs to be done?"/>
<button>create Todo</button>
</form>
<For each={todos()}> <For each={todos()}>
{(item:Todo) => {(todo: Todo) =>
<div> <div class="todo-row" data-flip-id={todo.id}>
{item.task} <span>{todo.task}</span>
<input type='checkbox' checked={item.done} onChange={setTodoDone(item.id || "",item.task)}/> <input type="checkbox" checked={todo.done} onChange={setTodoDone(todo.id || "", todo.task)}/>
<button onclick={deleteTodo(item.id || "")}>delete</button> <button onClick={deleteTodo(todo.id || "")}>delete</button>
</div> </div>
} }
</For> </For>
</div> </main>
); );
} }
@@ -10,10 +10,15 @@
"allowJs": true, "allowJs": true,
"strict": true, "strict": true,
"noEmit": true, "noEmit": true,
"types": ["vite/client"], "types": [
"vite/client"
],
"isolatedModules": true, "isolatedModules": true,
"paths": { "paths": {
"~/*": ["./src/*"] "~/*": [
} "./src/*"
]
},
"skipLibCheck": true
} }
} }
@@ -1,10 +1,14 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { nitroV2Plugin as nitro } from "@solidjs/vite-plugin-nitro-2"; import { nitro } from "nitro/vite";
import { solidStart } from "@solidjs/start/config"; import { solidStart } from "@solidjs/start/config";
export default defineConfig({ export default defineConfig({
plugins: [solidStart(), plugins: [solidStart(),
nitro() nitro()
] ],
// Same port as the other web templates, which the API allows via CORS.
server: { port: 5173 },
// SolidStart's dev toolbar imports this CommonJS package in the browser, which fails unless prebundled.
optimizeDeps: { include: ["@solidjs/start > @jridgewell/trace-mapping"] },
}); });
@@ -12,15 +12,20 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-auto": "^8.0.0",
"@sveltejs/kit": "^2.57.0", "@sveltejs/kit": "^3.0.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.2.0",
"svelte": "^5.55.2", "svelte": "^5.57.1",
"svelte-check": "^4.4.6", "svelte-check": "^4.6.0",
"typescript": "^6.0.2", "typescript": "^6.0.3",
"vite": "^8.0.7" "vite": "^8.3.0"
},
"imports": {
"#lib": "./src/lib/index.js",
"#lib/*": "./src/lib/*"
}, },
"dependencies": { "dependencies": {
"@<@var(context.project.name)>/rpc": "workspace:*" "@<@var(context.project.name)>/rpc": "workspace:*",
"gsap": "^3.15.0"
} }
} }
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
@@ -1 +1 @@
// place files you want to import through the `$lib` alias in this folder. // place files you want to import through the `#lib` alias in this folder.
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import favicon from '$lib/assets/favicon.svg'; import favicon from '#lib/assets/favicon.svg';
import type { LayoutProps } from './$types';
let { children } = $props(); let { children }: LayoutProps = $props();
</script> </script>
<svelte:head> <svelte:head>
@@ -1,51 +1,86 @@
<script lang="ts"> <script lang="ts">
import { createRouter, type Todo } from "@glstack-test/rpc" import { tick } from "svelte"
import type { ChangeEventHandler } from "svelte/elements"; import { gsap } from "gsap"
const router = createRouter("http://127.0.0.1:8080"); import { Flip } from "gsap/Flip"
import { createRouter, type Todo } from "@<@var(context.project.name)>/rpc"
gsap.registerPlugin(Flip)
const router = createRouter("http://127.0.0.1:8080")
// Changes made elsewhere (another tab, the mobile app) show up within this interval.
const syncIntervalMs = 2000
let todos = $state<Todo[]>([]) let todos = $state<Todo[]>([])
let offline = $state(false)
let todoToCreateTask = $state<string>("") let todoToCreateTask = $state<string>("")
// Like the mobile app: rows spring into their new place when the list re-sorts, new rows fade in.
const showTodos = async (next: Todo[]) => {
const rows = Flip.getState(".todo-row")
todos = next
await tick()
Flip.from(rows, {
targets: ".todo-row",
duration: 0.5,
ease: "back.out(1.4)",
onEnter: (entering) => gsap.fromTo(entering, { opacity: 0 }, { opacity: 1, duration: 0.3 }),
})
}
const fetchTodos = () => { const fetchTodos = () => {
router.todos.listTodos({}).then((r) => { router.todos.listTodos({}).then((r) => {
todos = r.todos; offline = false
}) // Polling returns the same list most of the time; skip re-rendering it.
if (JSON.stringify($state.snapshot(todos)) !== JSON.stringify(r.todos)) showTodos(r.todos)
}).catch(() => offline = true)
} }
$effect(() => { $effect(() => {
fetchTodos() fetchTodos()
const interval = setInterval(fetchTodos, syncIntervalMs)
return () => clearInterval(interval)
}) })
const setTodoDone = (id: string, task: string) => { const setTodoDone = (id: string, task: string) => {
return async (event : Event & { currentTarget: HTMLInputElement }) => { return (event: Event & { currentTarget: HTMLInputElement }) => {
await router.todos.updateTodo({ todo: { id, task, done: event.currentTarget.checked } }) router.todos.updateTodo({ todo: { id, task, done: event.currentTarget.checked } }).then(fetchTodos, () => offline = true)
fetchTodos()
} }
} }
const deleteTodo = (id: string) => { const deleteTodo = (id: string) => {
return async () => { return async (event: Event & { currentTarget: HTMLElement }) => {
await router.todos.deleteTodo({ todo: { id } }) await gsap.to(event.currentTarget.closest(".todo-row"), { opacity: 0, duration: 0.3 })
fetchTodos() router.todos.deleteTodo({ todo: { id } }).then(fetchTodos, () => offline = true)
} }
} }
const createTodo = () => { const createTodo = () => {
router.todos.createTodo({ todo: { id: crypto.randomUUID(), task: todoToCreateTask } }).then((r) => { const task = todoToCreateTask.trim()
console.log(r) if (!task) return
router.todos.createTodo({ todo: { id: crypto.randomUUID(), task } }).then(() => {
todoToCreateTask = ""
fetchTodos() fetchTodos()
}).catch((e) => { }, () => offline = true)
console.log(e)
})
} }
</script> </script>
<div> <main>
<h1>Create Todo</h1> <h1>Create Todo</h1>
<input bind:value={todoToCreateTask} type="text"/> {#if offline}<p class="offline">Can't reach the API, retrying…</p>{/if}
<button onclick={createTodo}> create </button> <form onsubmit={(event) => { event.preventDefault(); createTodo() }}>
<input bind:value={todoToCreateTask} type="text" placeholder="What needs to be done?"/>
<button>create Todo</button>
</form>
{#each todos as todo (todo.id)} {#each todos as todo (todo.id)}
<div> <div class="todo-row" data-flip-id={todo.id}>
{todo.task} <span>{todo.task}</span>
<input type='checkbox' onchange={setTodoDone(todo.id || "",todo.task)}/> <input type="checkbox" checked={todo.done} onchange={setTodoDone(todo.id || "", todo.task)}/>
<button onclick={deleteTodo(todo.id || "")}>delete</button> <button onclick={deleteTodo(todo.id || "")}>delete</button>
</div> </div>
{/each} {/each}
</div> </main>
<style>
main { max-width: 32rem; margin: 2rem auto; font-family: system-ui, sans-serif; }
form, .todo-row { display: flex; gap: 0.75rem; align-items: center; margin-bottom: 0.75rem; }
input[type="text"] { flex: 1; padding: 0.5rem; border: 1px solid #8e8e93; border-radius: 8px; }
.todo-row span { flex: 1; }
.offline { color: #d32f2f; }
</style>
@@ -1,17 +0,0 @@
import adapter from '@sveltejs/adapter-auto';
/** @type {import('@sveltejs/kit').Config} */
const config = {
compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
},
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;
@@ -1,20 +1,7 @@
{ {
"extends": "./.svelte-kit/tsconfig.json", "extends": "$app/tsconfig",
"compilerOptions": { "compilerOptions": {
"rewriteRelativeImportExtensions": true, "strict": true
"allowJs": true, },
"checkJs": true, "include": ["src", "vite.config.ts"]
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// To make changes to top-level options such as include and exclude, we recommend extending
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
} }
@@ -1,6 +1,20 @@
import adapter from '@sveltejs/adapter-auto';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()] plugins: [
sveltekit({
compilerOptions: {
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
runes: ({ filename }) =>
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
})
]
}); });
@@ -1,3 +1,3 @@
# Expo HAS CHANGED # Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v56.0.0/ before writing any code. Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.
@@ -8,7 +8,8 @@
"scheme": "mobile", "scheme": "mobile",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
"ios": { "ios": {
"icon": "./assets/expo.icon" "icon": "./assets/expo.icon",
"bundleIdentifier": "<@var(context.project.bundleid)>"
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
@@ -18,7 +19,7 @@
"monochromeImage": "./assets/images/android-icon-monochrome.png" "monochromeImage": "./assets/images/android-icon-monochrome.png"
}, },
"predictiveBackGestureEnabled": false, "predictiveBackGestureEnabled": false,
"package": "com.gregorl.mobile" "package": "<@var(context.project.bundleid)>"
}, },
"web": { "web": {
"output": "static", "output": "static",
@@ -35,7 +36,11 @@
"imageWidth": 76 "imageWidth": 76
} }
} }
] ],
"expo-font",
"expo-image",
"expo-status-bar",
"expo-web-browser"
], ],
"experiments": { "experiments": {
"typedRoutes": true, "typedRoutes": true,
@@ -3,32 +3,32 @@
"main": "expo-router/entry", "main": "expo-router/entry",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@expo/ui": "~56.0.14",
"@<@var(context.project.name)>/rpc": "workspace:*", "@<@var(context.project.name)>/rpc": "workspace:*",
"expo": "~56.0.5", "@expo/ui": "~57.0.22",
"expo-constants": "~56.0.16", "expo": "~57.0.27",
"expo-crypto": "~56.0.4", "expo-constants": "~57.0.21",
"expo-dev-client": "~56.0.18", "expo-crypto": "~57.0.3",
"expo-device": "~56.0.4", "expo-dev-client": "~57.0.19",
"expo-font": "~56.0.5", "expo-device": "~57.0.2",
"expo-glass-effect": "~56.0.4", "expo-font": "~57.0.4",
"expo-image": "~56.0.9", "expo-glass-effect": "~57.0.4",
"expo-linking": "~56.0.12", "expo-image": "~57.0.5",
"expo-router": "~56.2.7", "expo-linking": "~57.0.12",
"expo-splash-screen": "~56.0.10", "expo-router": "~57.0.25",
"expo-status-bar": "~56.0.4", "expo-splash-screen": "~57.0.9",
"expo-symbols": "~56.0.5", "expo-status-bar": "~57.0.1",
"expo-system-ui": "~56.0.5", "expo-symbols": "~57.0.3",
"expo-web-browser": "~56.0.5", "expo-system-ui": "~57.0.4",
"expo-web-browser": "~57.0.3",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-native": "0.85.3", "react-native": "0.86.3",
"react-native-gesture-handler": "~2.31.1", "react-native-gesture-handler": "~2.32.0",
"react-native-reanimated": "4.3.1", "react-native-reanimated": "4.5.1",
"react-native-safe-area-context": "~5.7.0", "react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2", "react-native-screens": "~4.26.0",
"react-native-web": "~0.21.0", "react-native-web": "~0.21.0",
"react-native-worklets": "0.8.3" "react-native-worklets": "0.10.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "~19.2.2", "@types/react": "~19.2.2",
@@ -1,43 +1,81 @@
import { createRouter, Todo } from "@<@var(context.project.name)>/rpc" import { createRouter, Todo } from "@<@var(context.project.name)>/rpc"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { ScrollView, Button, Checkbox, Host, Column, TextInput, Text, useNativeState, Row, Icon } from "@expo/ui"; import { Platform, PlatformColor, ScrollView, useColorScheme } from "react-native"
import Animated, { FadeIn, FadeOut, LinearTransition } from "react-native-reanimated"
import { SafeAreaView } from "react-native-safe-area-context"
import { Button, Checkbox, Host, Column, TextInput, Text, useNativeState, Row, Icon } from "@expo/ui";
import * as Crypto from "expo-crypto" import * as Crypto from "expo-crypto"
import { colorInvert, controlSize } from "@expo/ui/swift-ui/modifiers"; import { background, colorInvert, controlSize, padding as iosPadding, shapes } from "@expo/ui/swift-ui/modifiers";
import { fillMaxWidth, padding, width } from "@expo/ui/jetpack-compose/modifiers"; // No numeric width() on Android: @expo/ui 57 can't convert it natively and crashes.
import { fillMaxWidth, padding, weight } from "@expo/ui/jetpack-compose/modifiers";
// The iOS simulator shares the host's network; devenv runs `adb reverse` so Android devices do too.
const router = createRouter("http://127.0.0.1:8080")
// Changes made elsewhere (another device, the web app) show up within this interval.
const syncIntervalMs = 2000
// SwiftUI clips a border to its corner radius, cutting off the corners, so on iOS the
// border is an outer rounded fill showing 1pt around an inner one.
const iosInputBorder = Platform.OS === "ios" ? [
iosPadding({ all: 10 }),
background(PlatformColor("secondarySystemGroupedBackground"), shapes.roundedRectangle({ cornerRadius: 9, roundedCornerStyle: "continuous" })),
iosPadding({ all: 1 }),
background(PlatformColor("separator"), shapes.roundedRectangle({ cornerRadius: 10, roundedCornerStyle: "continuous" })),
] : []
// Animates its position when the list re-sorts. The Host's measured height goes through state:
// on Android a layout transition ignores the size matchContents sets natively, collapsing the row.
function AnimatedRow({ children }: { children: React.ReactNode }) {
const [height, setHeight] = useState<number>()
return (
<Animated.View layout={LinearTransition.springify()} entering={FadeIn} exiting={FadeOut}>
<Host
matchContents={{ vertical: true }}
style={height ? { height } : undefined}
onLayoutContent={(e) => setHeight(e.nativeEvent.height)}
>
{children}
</Host>
</Animated.View>
)
}
export default function Index() { export default function Index() {
const router = createRouter("http://10.0.2.2:8080")
const [todos, setTodos] = useState<Array<Todo>>([]) const [todos, setTodos] = useState<Array<Todo>>([])
const [offline, setOffline] = useState(false)
const todoToCreateTask = useNativeState<string>("") const todoToCreateTask = useNativeState<string>("")
const colorScheme = useColorScheme()
const fetchTodos = () => { const fetchTodos = () => {
router.todos.listTodos({}).then((r) => { router.todos.listTodos({}).then((r) => {
setTodos(r.todos) setOffline(false)
}) // Polling returns the same list most of the time; keep the old array to skip re-rendering.
setTodos((current) => JSON.stringify(current) === JSON.stringify(r.todos) ? current : r.todos)
}).catch(() => setOffline(true))
} }
useEffect(() => { useEffect(() => {
fetchTodos(); fetchTodos()
}) const interval = setInterval(fetchTodos, syncIntervalMs)
return () => clearInterval(interval)
}, [])
const setTodoDone = (id: string, task: string) => { const setTodoDone = (id: string, task: string) => {
return (done: boolean) => { return (done: boolean) => {
router.todos.updateTodo({ todo: { id, task, done } }) router.todos.updateTodo({ todo: { id, task, done } }).then(fetchTodos, () => setOffline(true))
fetchTodos()
} }
} }
const deleteTodo = (id: string) => { const deleteTodo = (id: string) => {
return () => { return () => {
router.todos.deleteTodo({ todo: { id } }) router.todos.deleteTodo({ todo: { id } }).then(fetchTodos, () => setOffline(true))
fetchTodos()
} }
} }
const createTodo = () => { const createTodo = () => {
router.todos.createTodo({ todo: { id: Crypto.randomUUID(), task: todoToCreateTask.value } }).then((r) => { const task = todoToCreateTask.value.trim()
console.log(r) if (!task) return
router.todos.createTodo({ todo: { id: Crypto.randomUUID(), task } }).then(() => {
todoToCreateTask.value = ""
fetchTodos() fetchTodos()
}).catch((e) => { }, () => setOffline(true))
console.log(e)
})
} }
const updateTodoToCreateTask = (task: string) => { const updateTodoToCreateTask = (task: string) => {
@@ -45,29 +83,44 @@ export default function Index() {
} }
return ( return (
<Host style={{ flex: 1 }}> <SafeAreaView style={{ flex: 1 }}>
<Host matchContents={{ vertical: true }}>
<Column alignment="center" modifiers={[fillMaxWidth()]}> <Column alignment="center" modifiers={[fillMaxWidth()]}>
<Text textStyle={{fontSize:30}}> Create Todo </Text> <Text textStyle={{fontSize:30}}> Create Todo </Text>
<TextInput value={todoToCreateTask} onChangeText={updateTodoToCreateTask} /> {offline && <Text textStyle={{ color: "#FF3B30" }}>Can't reach the API, retrying…</Text>}
<TextInput
value={todoToCreateTask}
onChangeText={updateTodoToCreateTask}
placeholder="What needs to be done?"
style={Platform.OS === "ios"
? { width: 280 }
// Android draws the border as a fill behind the background, so the background must be opaque.
: { padding: 10, borderWidth: 1, borderColor: "#8E8E93", borderRadius: 8, backgroundColor: colorScheme === "dark" ? "#1C1B1F" : "#FFFFFF" }}
modifiers={Platform.OS === "ios" ? iosInputBorder : [fillMaxWidth(0.75)]}
/>
<Button modifiers={[controlSize('regular')]} label="create Todo" onPress={createTodo} /> <Button modifiers={[controlSize('regular')]} label="create Todo" onPress={createTodo} />
<ScrollView modifiers={[fillMaxWidth()]}> </Column>
</Host>
<ScrollView>
{ {
todos.map((todo) => ( todos.map((todo) => (
// Keyed by id so native toggles follow their todo.
<AnimatedRow key={todo.id}>
<Row alignment="center" modifiers={[fillMaxWidth(),padding(20,0,20,0)]}> <Row alignment="center" modifiers={[fillMaxWidth(),padding(20,0,20,0)]}>
<Column alignment="start" modifiers={[width(200)]}> <Column alignment="start" modifiers={[weight(1)]}>
<Text>{todo.task}</Text> <Text>{todo.task}</Text>
</Column> </Column>
<Column alignment="center"> <Column alignment="center">
<Checkbox value={todo.done || false} onValueChange={setTodoDone(todo.id || "", todo.task)} /> <Checkbox value={todo.done || false} onValueChange={setTodoDone(todo.id || "", todo.task)} />
</Column> </Column>
<Column alignment="end" modifiers={[fillMaxWidth()]}> <Column alignment="end">
<Button modifiers={[controlSize('regular')]} label="delete" onPress={deleteTodo(todo.id || "")}/> <Button modifiers={[controlSize('regular')]} label="delete" onPress={deleteTodo(todo.id || "")}/>
</Column> </Column>
</Row> </Row>
</AnimatedRow>
)) ))
} }
</ScrollView> </ScrollView>
</Column> </SafeAreaView>
</Host>
) )
} }
+3
View File
@@ -0,0 +1,3 @@
[install]
# React Native expects one flat node_modules; isolated installs keep a copy per peer-dependency set.
linker = "hoisted"
+102 -15
View File
@@ -1,5 +1,19 @@
{ pkgs, lib, config, inputs, ... }: { pkgs, lib, config, inputs, ... }:
<@if(eq(context.project.mobile,"expo"))>
let
avdName = "Pixel_5_API36";
# Fixed console port, so scripts can address this emulator as emulator-5554 even when others run.
emulatorSerial = "emulator-5554";
# Apple's xcrun, for tools that need `xcrun simctl`, which the Nix SDK's xcrun lacks.
systemXcrun = pkgs.runCommand "system-xcrun" { } "mkdir -p $out/bin && ln -s /usr/bin/xcrun $out/bin/xcrun";
# Sets dev_dir to Xcode's developer dir. devenv points DEVELOPER_DIR at the Nix SDK,
# which xcode-select would echo back, so it is unset for the lookup.
xcodeDevDir = ''
dev_dir="$(/usr/bin/env -u DEVELOPER_DIR /usr/bin/xcode-select -p 2>/dev/null)"
[ -x "$dev_dir/usr/bin/simctl" ] || dev_dir=/Applications/Xcode.app/Contents/Developer
'';
in
<@endif>
{ {
packages = [ packages = [
pkgs.bun pkgs.bun
@@ -13,14 +27,14 @@
pkgs.protoc-gen-connect-go pkgs.protoc-gen-connect-go
pkgs.protoc-gen-es pkgs.protoc-gen-es
pkgs.cobra-cli pkgs.cobra-cli
]; ]<@if(eq(context.project.mobile,"expo"))> ++ lib.optionals pkgs.stdenv.isDarwin [ pkgs.cocoapods ]<@endif>;
<@if(eq(context.project.mobile,"expo"))> <@if(eq(context.project.mobile,"expo"))>
android = { android = {
enable = true; enable = true;
emulator = { emulator = {
enable = true; enable = true;
}; };
buildTools.version = ["34.0.0" "35.0.0" "36.0.0" ]; # add 36.0.0 buildTools.version = [ "34.0.0" "35.0.0" "36.0.0" ];
reactNative.enable = true; reactNative.enable = true;
android-studio = { android-studio = {
enable = false; enable = false;
@@ -30,15 +44,62 @@
version = [ "27.1.12297006" ]; version = [ "27.1.12297006" ];
}; };
}; };
enterShell = '' # The emulator's bundled libs are only needed on Linux; macOS uses DYLD paths.
enterShell = lib.optionalString pkgs.stdenv.isLinux ''
export LD_LIBRARY_PATH=$ANDROID_HOME/emulator/lib64:$LD_LIBRARY_PATH export LD_LIBRARY_PATH=$ANDROID_HOME/emulator/lib64:$LD_LIBRARY_PATH
''; '' + lib.optionalString pkgs.stdenv.isDarwin ''
scripts.create-avd.exec = '' # Re-check at most daily once the iOS requirements are met. SKIP_IOS_CHECK=1 turns this off.
if [ ! -f .avd-created ]; then ios_check_ok="$DEVENV_STATE/ios-check-ok"
avdmanager create avd -n "Pixel_5_API34" -k "system-images;android-34;google_apis_playstore;x86_64" -d "pixel_5"; if [ -z "''${SKIP_IOS_CHECK:-}" ] && [ -z "$(find "$ios_check_ok" -mtime -1 2>/dev/null)" ]; then
touch .avd-created; if ios-check; then touch "$ios_check_ok"; else echo "Set SKIP_IOS_CHECK=1 to hide this." >&2; fi
fi fi
''; '';
# Use a system image matching the host CPU (arm64 on Apple Silicon, x86_64 elsewhere).
scripts.create-avd.exec = ''
if ! avdmanager list avd -c | grep -qx "${avdName}"; then
echo no | avdmanager create avd -n "${avdName}" -k "system-images;android-36;google_apis_playstore;${if pkgs.stdenv.hostPlatform.isAarch64 then "arm64-v8a" else "x86_64"}" -d "pixel_5"
fi
'';
# macOS only: Nix can't provide Xcode, so report what the iOS build still needs.
scripts.ios-check = lib.mkIf pkgs.stdenv.isDarwin {
exec = ''
${xcodeDevDir}
export DEVELOPER_DIR="$dev_dir"
problems=""
missing() { problems+=" - $1"$'\n'; }
if [ ! -x "$dev_dir/usr/bin/simctl" ]; then
missing "Xcode is not installed. Install it from the App Store: https://apps.apple.com/app/xcode/id497799835"
else
if ! /usr/bin/xcodebuild -license check >/dev/null 2>&1 || ! /usr/bin/xcodebuild -checkFirstLaunchStatus >/dev/null 2>&1; then
missing "Xcode setup is incomplete (license or first-launch components). Run: sudo xcodebuild -runFirstLaunch"
fi
if ! /usr/bin/xcrun simctl list runtimes 2>/dev/null | grep -q '^iOS '; then
missing "No iOS simulator runtime is installed. Run: xcodebuild -downloadPlatform iOS"
elif ! /usr/bin/xcrun simctl list devices available 2>/dev/null | grep -q iPhone; then
missing "No iPhone simulator exists. Create one in Xcode > Window > Devices and Simulators"
fi
fi
if [ -n "$problems" ]; then
printf 'iOS simulator builds are disabled until this is fixed:\n%s' "$problems" >&2
exit 1
fi
'';
};
# macOS only: run on the iOS simulator when ios-check passes.
scripts.run-ios = lib.mkIf pkgs.stdenv.isDarwin {
exec = ''
if ! ios-check; then
echo "Skipping iOS."
exit 0
fi
${xcodeDevDir}
# devenv's Nix Apple SDK, xcrun and compiler wrappers break xcodebuild, so build in a clean env.
exec /usr/bin/env -i HOME="$HOME" USER="$USER" TERM="$TERM" LANG=en_US.UTF-8 \
DEVELOPER_DIR="$dev_dir" \
PATH="${lib.makeBinPath [ pkgs.bun pkgs.nodejs_24 pkgs.cocoapods ]}:/usr/bin:/bin:/usr/sbin:/sbin" \
bunx expo run:ios --no-bundler "$@"
'';
};
<@endif> <@endif>
languages.go.enable = true; languages.go.enable = true;
languages.typescript.enable = true; languages.typescript.enable = true;
@@ -88,16 +149,42 @@
exec = "create-avd"; exec = "create-avd";
}; };
emulator = { emulator = {
exec = "emulator -avd Pixel_5_API34"; exec = "emulator -avd ${avdName} -port 5554";
after = ["devenv:processes:createavd@completed"]; after = ["devenv:processes:createavd@completed"];
}; };
expodev = { # One Metro bundler serves both apps, so iOS doesn't depend on the Android build.
exec = "bunx expo run:android"; metro = {
exec = lib.optionalString pkgs.stdenv.isDarwin ''
# Expo's CLI calls `xcrun simctl` to talk to the iOS simulator.
${xcodeDevDir}
export DEVELOPER_DIR="$dev_dir" PATH="${systemXcrun}/bin:$PATH"
'' + "exec bunx expo start --dev-client";
cwd = "./apps/mobile"; cwd = "./apps/mobile";
after= ["devenv:processes:emulator@started"]; ready.http.get = { port = 8081; path = "/status"; };
restart = {
on = "always";
}; };
expodev = {
exec = ''
# Wait for the emulator to boot, otherwise expo tries to start a second copy of the AVD.
for _ in $(seq 150); do
if [ "$(adb -s ${emulatorSerial} shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = 1 ]; then
# Lets the emulator and USB-connected devices reach the API on the host at 127.0.0.1:8080.
for device in $(adb devices | awk 'NR > 1 && $2 == "device" { print $1 }'); do
adb -s "$device" reverse tcp:8080 tcp:8080
done
exec bunx expo run:android --no-bundler --device ${avdName}
fi
sleep 2
done
echo "The Android emulator didn't finish booting within 5 minutes, see the emulator log." >&2
exit 1
'';
cwd = "./apps/mobile";
after = [ "devenv:processes:emulator@started" "devenv:processes:metro" ];
};
expoios = lib.mkIf pkgs.stdenv.isDarwin {
exec = "run-ios";
cwd = "./apps/mobile";
after = [ "devenv:processes:metro" ];
}; };
<@endif> <@endif>
sqlwatcher = { sqlwatcher = {
+6 -2
View File
@@ -3,10 +3,14 @@
"private": true, "private": true,
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*",
"scripts"
], ],
"scripts": { "scripts": {
"dev": "bun run --filter '*' dev", "dev": "bun run --filter '*' dev",
"build": "bun run --filter '*' build" "build": "bun run --filter '*' build"
} }<@if(eq(context.project.mobile,"expo"))>,
"overrides": {
"react-native-screens": "~4.26.0"
}<@endif>
} }
+1 -3
View File
@@ -32,9 +32,7 @@ var serveCmd = &cobra.Command{
} }
conf := *config.Conf conf := *config.Conf
ctx := context.Background() ctx := context.Background()
db_url := "postgres://" + conf.Database.User + ":" + conf.Database.Password + "@" + conf.Database.Host + ":" + strconv.Itoa(conf.Database.Port) + "/" + conf.Database.Name conn, err := pgxpool.New(ctx, conf.Database.URL().String())
db_url = db_url + "?sslmode=disable"
conn, err := pgxpool.New(ctx, db_url)
if err != nil { if err != nil {
panic(err.Error()) panic(err.Error())
} }
+15 -1
View File
@@ -3,8 +3,11 @@ package config
import ( import (
"github.com/adrg/xdg" "github.com/adrg/xdg"
"github.com/pelletier/go-toml" "github.com/pelletier/go-toml"
"net"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
) )
var confPath string var confPath string
@@ -18,7 +21,7 @@ func init() {
Server: Server{ Server: Server{
Host: "127.0.0.1", Host: "127.0.0.1",
Port: 8080, Port: 8080,
FrontendUrls: []string{"http://localhost:5173"}, FrontendUrls: []string{"http://localhost:5173", "http://127.0.0.1:5173"},
}, },
Database: Database{ Database: Database{
User: "pp", User: "pp",
@@ -75,6 +78,17 @@ type Database struct {
Name string Name string
} }
// URL returns the Postgres connection URL. The local dev database has no SSL.
func (d Database) URL() *url.URL {
return &url.URL{
Scheme: "postgres",
User: url.UserPassword(d.User, d.Password),
Host: net.JoinHostPort(d.Host, strconv.Itoa(d.Port)),
Path: "/" + d.Name,
RawQuery: "sslmode=disable",
}
}
type Config struct { type Config struct {
Server Server Server Server
Database Database Database Database
+1 -19
View File
@@ -5,9 +5,6 @@ import (
"github.com/amacneil/dbmate/v2/pkg/dbmate" "github.com/amacneil/dbmate/v2/pkg/dbmate"
_ "github.com/amacneil/dbmate/v2/pkg/driver/postgres" _ "github.com/amacneil/dbmate/v2/pkg/driver/postgres"
"log" "log"
"net/url"
"strconv"
"strings"
) )
var Q *Queries var Q *Queries
@@ -19,22 +16,7 @@ func RunDbMigrations() error {
return err return err
} }
conf := *config.Conf conf := *config.Conf
dbUrl, err := url.Parse(strings.Join([]string{ db := dbmate.New(conf.Database.URL())
"postgresql://",
conf.Database.User,
":",
conf.Database.Password,
"@",
conf.Database.Host,
":",
strconv.Itoa(conf.Database.Port),
"/",
conf.Database.Name,
}, ""))
if err != nil {
return err
}
db := dbmate.New(dbUrl)
db.FS = MigrationsFs db.FS = MigrationsFs
db.MigrationsDir = []string{"migrations"} db.MigrationsDir = []string{"migrations"}
migrations, err := db.FindMigrations() migrations, err := db.FindMigrations()
+27 -24
View File
@@ -1,34 +1,37 @@
module <@var(context.project.goprefix)>/<@var(context.project.name)> module <@var(context.project.goprefix)>/<@var(context.project.name)>
go 1.25.7 go 1.26.0
require ( require (
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.9-20250912141014-52f32327d4b0.1 // indirect connectrpc.com/connect v1.21.0
buf.build/go/protovalidate v1.0.0 // indirect connectrpc.com/cors v0.1.0
cel.dev/expr v0.24.0 // indirect connectrpc.com/validate v0.7.0
connectrpc.com/connect v1.19.1 // indirect github.com/adrg/xdg v0.5.3
connectrpc.com/cors v0.1.0 // indirect github.com/amacneil/dbmate/v2 v2.36.0
connectrpc.com/validate v0.6.0 // indirect github.com/jackc/pgx/v5 v5.11.0
github.com/adrg/xdg v0.5.3 // indirect github.com/pelletier/go-toml v1.9.5
github.com/amacneil/dbmate/v2 v2.32.0 // indirect github.com/rs/cors v1.11.1
github.com/spf13/cobra v1.10.2
google.golang.org/protobuf v1.36.12
)
require (
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect
buf.build/go/protovalidate v1.4.0 // indirect
cel.dev/cel-go v0.32.0 // indirect
cel.dev/expr v0.25.3 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/google/cel-go v0.26.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect github.com/jackc/puddle/v2 v2.2.3 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/lib/pq v1.12.3 // indirect
github.com/lib/pq v1.12.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/rs/cors v1.11.1 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/stoewer/go-strcase v1.3.1 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/exp v0.0.0-20261005173118-76772065c9b0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.42.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 // indirect
google.golang.org/protobuf v1.36.11 // indirect
) )
+52 -48
View File
@@ -1,41 +1,52 @@
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.9-20250912141014-52f32327d4b0.1 h1:DQLS/rRxLHuugVzjJU5AvOwD57pdFl9he/0O7e5P294= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 h1:NbnlmV26O7oZ1iM5tsCI+GEx+3ZSdrhvnKQ/eWSrLiY=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.9-20250912141014-52f32327d4b0.1/go.mod h1:aY3zbkNan5F+cGm9lITDP6oxJIwu0dn9KjJuJjWaHkg= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw=
buf.build/go/protovalidate v1.0.0 h1:IAG1etULddAy93fiBsFVhpj7es5zL53AfB/79CVGtyY= buf.build/go/protovalidate v1.4.0 h1:UjLrYbt5VX7+TMOs2+pG5FhZhIG1mSfK4EIopbb4LcM=
buf.build/go/protovalidate v1.0.0/go.mod h1:KQmEUrcQuC99hAw+juzOEAmILScQiKBP1Oc36vvCLW8= buf.build/go/protovalidate v1.4.0/go.mod h1:8vJfzNT6NIG2qm3uFsJDXMlRmG+bQJzbcIn1Aa0vPGs=
cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk=
cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU=
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0=
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
connectrpc.com/connect v1.21.0 h1:LhqSJt7jHf5NJBo9Jq/t/9FjcYAideif0mg+qe2jCUs=
connectrpc.com/connect v1.21.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
connectrpc.com/cors v0.1.0 h1:f3gTXJyDZPrDIZCQ567jxfD9PAIpopHiRDnJRt3QuOQ= connectrpc.com/cors v0.1.0 h1:f3gTXJyDZPrDIZCQ567jxfD9PAIpopHiRDnJRt3QuOQ=
connectrpc.com/cors v0.1.0/go.mod h1:v8SJZCPfHtGH1zsm+Ttajpozd4cYIUryl4dFB6QEpfg= connectrpc.com/cors v0.1.0/go.mod h1:v8SJZCPfHtGH1zsm+Ttajpozd4cYIUryl4dFB6QEpfg=
connectrpc.com/validate v0.6.0 h1:DcrgDKt2ZScrUs/d/mh9itD2yeEa0UbBBa+i0mwzx+4= connectrpc.com/validate v0.7.0 h1:E4wQiBymFW+GGWMwV4fO4yrvhIdzEVXlgRiZaSer/Hs=
connectrpc.com/validate v0.6.0/go.mod h1:ihrpI+8gVbLH1fvVWJL1I3j0CfWnF8P/90LsmluRiZs= connectrpc.com/validate v0.7.0/go.mod h1:BD3Onoa126tmoI3YG13UGFjBylgkd5LJ5whrAwi/wgg=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/amacneil/dbmate/v2 v2.32.0 h1:DJWt+NRWlphTv1HXWWcAU7gsJKm/Ov8/+8k6yqLz8vA= github.com/amacneil/dbmate/v2 v2.36.0 h1:oWxpNxkPSJ57UNaP9x/Icj/Kw12QNiWVzNnBLWkHcLE=
github.com/amacneil/dbmate/v2 v2.32.0/go.mod h1:Kwax92bT+ZgqIXll9M+0QGuHVuu7O7AhsE+5p6PTqDc= github.com/amacneil/dbmate/v2 v2.36.0/go.mod h1:9B5Xs1OZ3kJUF739cgWhRDE4qrXs5hoRmTrgnvrpJH8=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.3 h1:f8HoREISb3l5eO9coPgK7U3hKLhWGokOFVcThl5jrao=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.3/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8=
github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -44,36 +55,29 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs=
github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/zenizh/go-capturer v0.0.0-20211219060012-52ea6c8fed04 h1:qXafrlZL1WsJW5OokjraLLRURHiw0OzKHD/RNdspp4w=
github.com/zenizh/go-capturer v0.0.0-20211219060012-52ea6c8fed04/go.mod h1:FiwNQxz6hGoNFBC4nIx+CxZhI3nne5RmIOlT/MXcSD4=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/exp v0.0.0-20261005173118-76772065c9b0 h1:qUp3i6M39kccSm3OMMIE3tvs7YQWO9rFeCKdk1sSaFk=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/exp v0.0.0-20261005173118-76772065c9b0/go.mod h1:SRjTzy5WcYqRRqquLUvHeeovSwLO0CZ3xcltZNO4Fck=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8 h1:cFbkNigDInw+wVTXc+CyhUsoHGJCw0BR6mlNoRVfre0=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8/go.mod h1:QK6p7IgD7IZEZJBhhSUCib5X2vMOzlrGCGUw2XyVDh0=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 h1:0SXBqli5dnuCKMjI/X3FM226zEaEa0GUcSCJtN1vL2o=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+12 -12
View File
@@ -9,10 +9,10 @@ import (
"connectrpc.com/connect" "connectrpc.com/connect"
"connectrpc.com/validate" "connectrpc.com/validate"
"github.com/<@var(context.project.name)>/<@var(context.project.name)>/db" "<@var(context.project.goprefix)>/<@var(context.project.name)>/db"
todov1 "<@var(context.project.goprefix)>/<@var(context.project.name)>/gen/todo/v1" todov1 "<@var(context.project.goprefix)>/<@var(context.project.name)>/gen/todo/v1"
"<@var(context.project.goprefix)>/<@var(context.project.name)>/gen/todo/v1/todov1connect" "<@var(context.project.goprefix)>/<@var(context.project.name)>/gen/todo/v1/todov1connect"
. "<@var(context.project.goprefix)>/glstack-test/utils" . "<@var(context.project.goprefix)>/<@var(context.project.name)>/utils"
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
@@ -59,20 +59,20 @@ func (srv *TodoServer) ListTodos(ctx context.Context, req *connect.Request[todov
Done: BoolPtr(todo.Done.Bool), Done: BoolPtr(todo.Done.Bool),
}) })
} }
slices.SortFunc(responseTodos, func(a, b *todov1.Todo) int { // Open todos first, each group oldest first. A single total order (SortFunc isn't stable)
dateCmp := cmp.Compare(a.GetCreatedAt(), b.GetCreatedAt()) // keeps rows from swapping places between polls.
return dateCmp boolToInt := func(b bool) int {
})
slices.SortFunc(responseTodos, func(a, b *todov1.Todo) int {
var boolToInt = func(b bool) int {
if b { if b {
return 1 return 1
} else { }
return 0 return 0
} }
} slices.SortFunc(responseTodos, func(a, b *todov1.Todo) int {
doneCmp := cmp.Compare(boolToInt(*a.Done), boolToInt(*b.Done)) return cmp.Or(
return doneCmp cmp.Compare(boolToInt(a.GetDone()), boolToInt(b.GetDone())),
cmp.Compare(a.GetCreatedAt(), b.GetCreatedAt()),
cmp.Compare(a.GetId(), b.GetId()),
)
}) })
return &connect.Response[todov1.ListTodosResponse]{ return &connect.Response[todov1.ListTodosResponse]{