Compare commits
2
Commits
c7e7e615c2
...
6afe1f7b67
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6afe1f7b67 | ||
|
|
8e3abca9d7 |
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: x86
|
||||
runs-on: local
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -6,6 +6,7 @@ Supported web frontends:
|
||||
|
||||
- [SvelteKit](https://kit.svelte.dev/)
|
||||
- [Solid Start](https://start.solidjs.com/)
|
||||
- [Nuxt](https://nuxt.com/)
|
||||
|
||||
Supported mobile frontends:
|
||||
|
||||
@@ -19,8 +20,8 @@ bun create glstack
|
||||
|
||||
The CLI asks for:
|
||||
|
||||
- **Project name** -- used for the directory, the database and the `@<name>/rpc` package
|
||||
- **Web frontend** -- SvelteKit, Solid Start or none
|
||||
- **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>`
|
||||
@@ -31,7 +32,7 @@ The CLI asks for:
|
||||
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
|
||||
- **`apps/web`** -- Web frontend app (SvelteKit or Solid Start)
|
||||
- **`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/rpc`** -- Generated TypeScript Connect RPC client shared across frontend apps
|
||||
@@ -58,15 +59,17 @@ devenv up
|
||||
|
||||
### Mobile (Expo)
|
||||
|
||||
`devenv up` creates an Android emulator matching your CPU (arm64 or x86_64), waits 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 share one Metro bundler, so saving a file reloads both.
|
||||
`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 Android emulator reaches the API at `10.0.2.2:8080` and the iOS simulator at `127.0.0.1:8080`; the app picks the right one per platform.
|
||||
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 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 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; run `ios-check` to check again. Until they are met, `devenv up` skips iOS and runs everything else.
|
||||
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 |
|
||||
| --- | --- |
|
||||
@@ -79,7 +82,7 @@ CocoaPods comes from Nix. The iOS build uses `/Applications/Xcode.app` (or the X
|
||||
|
||||
### 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. In the mobile app, rows animate into place with [Reanimated](https://docs.swmansion.com/react-native-reanimated/) when a todo is checked off and the list re-sorts.
|
||||
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
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"name": "glstack",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@gregorlohaus/tdir": "^0.2.0",
|
||||
"@gregorlohaus/tdir": "^0.3.0",
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
"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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import * as p from "@clack/prompts";
|
||||
import { initRenderer } from '@gregorlohaus/tdir'
|
||||
import { initRenderer, TargetExistsError } from '@gregorlohaus/tdir'
|
||||
import { z } from 'zod'
|
||||
import path from "node:path";
|
||||
import { readdir, rename } from "node:fs/promises";
|
||||
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(
|
||||
{
|
||||
name: () =>
|
||||
p.text({
|
||||
message: "What would you like to name your project?",
|
||||
placeholder: "glstack-test",
|
||||
defaultValue: "glstack-test",
|
||||
placeholder: defaultName,
|
||||
defaultValue: defaultName,
|
||||
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
|
||||
},
|
||||
}),
|
||||
@@ -25,6 +38,7 @@ const project = await p.group(
|
||||
options: [
|
||||
{ value: "svelte-kit", label: "SvelteKit" },
|
||||
{ value: "solid-start", label: "SolidStart" },
|
||||
{ value: "nuxt", label: "Nuxt" },
|
||||
{ value: "none", label: "None" }
|
||||
]
|
||||
})
|
||||
@@ -41,15 +55,19 @@ 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 fallback = `com.${String(results.name).toLowerCase().replace(/[^a-z0-9]/g, "").replace(/^[0-9]+/, "") || "app"}.mobile`
|
||||
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 && !/^[a-zA-Z][a-zA-Z0-9]*(\.[a-zA-Z][a-zA-Z0-9]*)+$/.test(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
|
||||
},
|
||||
})
|
||||
@@ -60,6 +78,9 @@ const project = await p.group(
|
||||
placeholder: "github.com/glstack-test",
|
||||
defaultValue: "github.com/glstack-test",
|
||||
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
|
||||
},
|
||||
}),
|
||||
@@ -87,23 +108,18 @@ const render = createRenderer(z.object({
|
||||
})
|
||||
}))
|
||||
const destDir = path.join("./", project.name);
|
||||
// `results` in the bundleid prompt makes group() infer it as unknown.
|
||||
render(destDir, { project: { ...project, bundleid: project.bundleid as string } }, { reverseMap: true })
|
||||
|
||||
// npm never publishes .gitignore/.npmrc, so the template stores them as _gitignore/_npmrc.
|
||||
const dotfiles: Record<string, string> = { _gitignore: ".gitignore", _npmrc: ".npmrc" }
|
||||
for (const file of await readdir(destDir, { recursive: true })) {
|
||||
const dotname = dotfiles[path.basename(file)]
|
||||
if (dotname) await rename(path.join(destDir, file), path.join(destDir, path.dirname(file), dotname))
|
||||
}
|
||||
const mapFile = Bun.file(path.join(destDir, ".tdir-map.json"))
|
||||
if (await mapFile.exists()) {
|
||||
const map = await mapFile.json()
|
||||
for (const entry of map.files) {
|
||||
const dotname = dotfiles[path.basename(entry.outputPath)]
|
||||
if (dotname) entry.outputPath = path.join(path.dirname(entry.outputPath), dotname)
|
||||
}
|
||||
await Bun.write(mapFile, JSON.stringify(map, null, 2))
|
||||
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();
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"create-glstack": "./dist/index.js"
|
||||
"create-glstack": "dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@gregorlohaus/tdir": "^0.2.0",
|
||||
"@gregorlohaus/tdir": "^0.3.0",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"start": "vite start",
|
||||
"preview": "vite preview"
|
||||
"start": "node .output/server/index.mjs",
|
||||
"preview": "vite preview",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@<@var(context.project.name)>/rpc": "workspace:*",
|
||||
"@solidjs/start": "2.0.0-alpha.2",
|
||||
"@solidjs/vite-plugin-nitro-2": "^0.1.0",
|
||||
"solid-js": "^1.9.5",
|
||||
"vite": "^7.0.0"
|
||||
"@solidjs/start": "^2.0.0",
|
||||
"solid-js": "^1.9.14",
|
||||
"vite": "^8.0.0",
|
||||
"nitro": "^3.0.260610-beta",
|
||||
"gsap": "^3.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=24"
|
||||
},
|
||||
"devDependencies": {}
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,61 +1,5 @@
|
||||
body {
|
||||
font-family: Gordita, Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
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);
|
||||
}
|
||||
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; }
|
||||
|
||||
@@ -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 { createEffect } from 'solid-js'
|
||||
import "./app.css";
|
||||
|
||||
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
|
||||
|
||||
export default function App() {
|
||||
const router = createRouter("http://127.0.0.1:8080")
|
||||
const [todos, setTodos] = createSignal<Todo[]>([]);
|
||||
const [offline, setOffline] = createSignal(false);
|
||||
const [todoToCreateTask, setTodoToCreateTask] = createSignal<string>("");
|
||||
const fetchTodos = () => {
|
||||
router.todos.listTodos({}).then((r) => {
|
||||
setTodos(r.todos)
|
||||
|
||||
// Like the mobile app: rows spring into their new place when the list re-sorts, new rows fade in.
|
||||
// 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()
|
||||
const interval = setInterval(fetchTodos, syncIntervalMs)
|
||||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
const setTodoDone = (id: string, task: string) => {
|
||||
return async (event : Event & { currentTarget: HTMLInputElement }) => {
|
||||
await router.todos.updateTodo({ todo: { id, task, done: event.currentTarget.checked } })
|
||||
fetchTodos()
|
||||
return (event: Event & { currentTarget: HTMLInputElement }) => {
|
||||
router.todos.updateTodo({ todo: { id, task, done: event.currentTarget.checked } }).then(fetchTodos, () => setOffline(true))
|
||||
}
|
||||
}
|
||||
|
||||
const deleteTodo = (id: string) => {
|
||||
return async () => {
|
||||
await router.todos.deleteTodo({ todo: { id } })
|
||||
fetchTodos()
|
||||
return async (event: Event & { currentTarget: HTMLElement }) => {
|
||||
await gsap.to(event.currentTarget.closest(".todo-row"), { opacity: 0, duration: 0.3 })
|
||||
router.todos.deleteTodo({ todo: { id } }).then(fetchTodos, () => setOffline(true))
|
||||
}
|
||||
}
|
||||
|
||||
const createTodo = () => {
|
||||
router.todos.createTodo({ todo: { id: crypto.randomUUID(), task: todoToCreateTask() } }).then((r) => {
|
||||
console.log(r)
|
||||
const task = todoToCreateTask().trim()
|
||||
if (!task) return
|
||||
router.todos.createTodo({ todo: { id: crypto.randomUUID(), task } }).then(() => {
|
||||
setTodoToCreateTask("")
|
||||
fetchTodos()
|
||||
}).catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
}, () => setOffline(true))
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Create Todo</p>
|
||||
<input value={todoToCreateTask()} onInput={(e) => setTodoToCreateTask(e.currentTarget.value)} type="text"/>
|
||||
<button onclick={createTodo}> create </button>
|
||||
<main>
|
||||
<h1>Create Todo</h1>
|
||||
<Show when={offline()}><p class="offline">Can't reach the API, retrying…</p></Show>
|
||||
<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()}>
|
||||
{(item:Todo) =>
|
||||
<div>
|
||||
{item.task}
|
||||
<input type='checkbox' checked={item.done} onChange={setTodoDone(item.id || "",item.task)}/>
|
||||
<button onclick={deleteTodo(item.id || "")}>delete</button>
|
||||
{(todo: Todo) =>
|
||||
<div class="todo-row" data-flip-id={todo.id}>
|
||||
<span>{todo.task}</span>
|
||||
<input type="checkbox" checked={todo.done} onChange={setTodoDone(todo.id || "", todo.task)}/>
|
||||
<button onClick={deleteTodo(todo.id || "")}>delete</button>
|
||||
</div>
|
||||
}
|
||||
</For>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,10 +10,15 @@
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"],
|
||||
"types": [
|
||||
"vite/client"
|
||||
],
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
"~/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
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";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [solidStart(),
|
||||
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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/kit": "^2.57.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"svelte": "^5.55.2",
|
||||
"svelte-check": "^4.4.6",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.7"
|
||||
"@sveltejs/adapter-auto": "^8.0.0",
|
||||
"@sveltejs/kit": "^3.0.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.2.0",
|
||||
"svelte": "^5.57.1",
|
||||
"svelte-check": "^4.6.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.3.0"
|
||||
},
|
||||
"imports": {
|
||||
"#lib": "./src/lib/index.js",
|
||||
"#lib/*": "./src/lib/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@<@var(context.project.name)>/rpc": "workspace:*"
|
||||
"@<@var(context.project.name)>/rpc": "workspace:*",
|
||||
"gsap": "^3.15.0"
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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.
|
||||
|
||||
+3
-2
@@ -1,7 +1,8 @@
|
||||
<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>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
+57
-22
@@ -1,51 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte"
|
||||
import { gsap } from "gsap"
|
||||
import { Flip } from "gsap/Flip"
|
||||
import { createRouter, type Todo } from "@<@var(context.project.name)>/rpc"
|
||||
import type { ChangeEventHandler } from "svelte/elements";
|
||||
const router = createRouter("http://127.0.0.1:8080");
|
||||
|
||||
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 offline = $state(false)
|
||||
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 = () => {
|
||||
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(() => {
|
||||
fetchTodos()
|
||||
const interval = setInterval(fetchTodos, syncIntervalMs)
|
||||
return () => clearInterval(interval)
|
||||
})
|
||||
|
||||
const setTodoDone = (id: string, task: string) => {
|
||||
return async (event : Event & { currentTarget: HTMLInputElement }) => {
|
||||
await router.todos.updateTodo({ todo: { id, task, done: event.currentTarget.checked } })
|
||||
fetchTodos()
|
||||
return (event: Event & { currentTarget: HTMLInputElement }) => {
|
||||
router.todos.updateTodo({ todo: { id, task, done: event.currentTarget.checked } }).then(fetchTodos, () => offline = true)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteTodo = (id: string) => {
|
||||
return async () => {
|
||||
await router.todos.deleteTodo({ todo: { id } })
|
||||
fetchTodos()
|
||||
return async (event: Event & { currentTarget: HTMLElement }) => {
|
||||
await gsap.to(event.currentTarget.closest(".todo-row"), { opacity: 0, duration: 0.3 })
|
||||
router.todos.deleteTodo({ todo: { id } }).then(fetchTodos, () => offline = true)
|
||||
}
|
||||
}
|
||||
|
||||
const createTodo = () => {
|
||||
router.todos.createTodo({ todo: { id: crypto.randomUUID(), task: todoToCreateTask } }).then((r) => {
|
||||
console.log(r)
|
||||
const task = todoToCreateTask.trim()
|
||||
if (!task) return
|
||||
router.todos.createTodo({ todo: { id: crypto.randomUUID(), task } }).then(() => {
|
||||
todoToCreateTask = ""
|
||||
fetchTodos()
|
||||
}).catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
}, () => offline = true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<main>
|
||||
<h1>Create Todo</h1>
|
||||
<input bind:value={todoToCreateTask} type="text"/>
|
||||
<button onclick={createTodo}> create </button>
|
||||
{#if offline}<p class="offline">Can't reach the API, retrying…</p>{/if}
|
||||
<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)}
|
||||
<div>
|
||||
{todo.task}
|
||||
<input type='checkbox' onchange={setTodoDone(todo.id || "",todo.task)}/>
|
||||
<div class="todo-row" data-flip-id={todo.id}>
|
||||
<span>{todo.task}</span>
|
||||
<input type="checkbox" checked={todo.done} onchange={setTodoDone(todo.id || "", todo.task)}/>
|
||||
<button onclick={deleteTodo(todo.id || "")}>delete</button>
|
||||
</div>
|
||||
{/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": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"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
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
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
|
||||
|
||||
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,8 +8,10 @@ import * as Crypto from "expo-crypto"
|
||||
import { background, colorInvert, controlSize, padding as iosPadding, shapes } from "@expo/ui/swift-ui/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 Android emulator reaches the host via 10.0.2.2; the iOS simulator shares the host's network.
|
||||
const apiUrl = Platform.OS === "android" ? "http://10.0.2.2:8080" : "http://127.0.0.1:8080"
|
||||
// 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.
|
||||
@@ -38,38 +40,42 @@ function AnimatedRow({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
export default function Index() {
|
||||
const router = createRouter(apiUrl)
|
||||
const [todos, setTodos] = useState<Array<Todo>>([])
|
||||
const [offline, setOffline] = useState(false)
|
||||
const todoToCreateTask = useNativeState<string>("")
|
||||
const colorScheme = useColorScheme()
|
||||
const fetchTodos = () => {
|
||||
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(() => {
|
||||
fetchTodos();
|
||||
fetchTodos()
|
||||
const interval = setInterval(fetchTodos, syncIntervalMs)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const setTodoDone = (id: string, task: string) => {
|
||||
return (done: boolean) => {
|
||||
router.todos.updateTodo({ todo: { id, task, done } }).then(fetchTodos)
|
||||
router.todos.updateTodo({ todo: { id, task, done } }).then(fetchTodos, () => setOffline(true))
|
||||
}
|
||||
}
|
||||
|
||||
const deleteTodo = (id: string) => {
|
||||
return () => {
|
||||
router.todos.deleteTodo({ todo: { id } }).then(fetchTodos)
|
||||
router.todos.deleteTodo({ todo: { id } }).then(fetchTodos, () => setOffline(true))
|
||||
}
|
||||
}
|
||||
|
||||
const createTodo = () => {
|
||||
router.todos.createTodo({ todo: { id: Crypto.randomUUID(), task: todoToCreateTask.value } }).then((r) => {
|
||||
console.log(r)
|
||||
const task = todoToCreateTask.value.trim()
|
||||
if (!task) return
|
||||
router.todos.createTodo({ todo: { id: Crypto.randomUUID(), task } }).then(() => {
|
||||
todoToCreateTask.value = ""
|
||||
fetchTodos()
|
||||
}).catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
}, () => setOffline(true))
|
||||
}
|
||||
|
||||
const updateTodoToCreateTask = (task: string) => {
|
||||
@@ -81,6 +87,7 @@ export default function Index() {
|
||||
<Host matchContents={{ vertical: true }}>
|
||||
<Column alignment="center" modifiers={[fillMaxWidth()]}>
|
||||
<Text textStyle={{fontSize:30}}> Create Todo </Text>
|
||||
{offline && <Text textStyle={{ color: "#FF3B30" }}>Can't reach the API, retrying…</Text>}
|
||||
<TextInput
|
||||
value={todoToCreateTask}
|
||||
onChangeText={updateTodoToCreateTask}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[install]
|
||||
# React Native expects one flat node_modules; isolated installs keep a copy per peer-dependency set.
|
||||
linker = "hoisted"
|
||||
+40
-16
@@ -1,6 +1,11 @@
|
||||
{ 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 = ''
|
||||
@@ -22,14 +27,14 @@ in
|
||||
pkgs.protoc-gen-connect-go
|
||||
pkgs.protoc-gen-es
|
||||
pkgs.cobra-cli
|
||||
];
|
||||
]<@if(eq(context.project.mobile,"expo"))> ++ lib.optionals pkgs.stdenv.isDarwin [ pkgs.cocoapods ]<@endif>;
|
||||
<@if(eq(context.project.mobile,"expo"))>
|
||||
android = {
|
||||
enable = true;
|
||||
emulator = {
|
||||
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;
|
||||
android-studio = {
|
||||
enable = false;
|
||||
@@ -43,12 +48,16 @@ in
|
||||
enterShell = lib.optionalString pkgs.stdenv.isLinux ''
|
||||
export LD_LIBRARY_PATH=$ANDROID_HOME/emulator/lib64:$LD_LIBRARY_PATH
|
||||
'' + lib.optionalString pkgs.stdenv.isDarwin ''
|
||||
ios-check || true
|
||||
# Re-check at most daily once the iOS requirements are met. SKIP_IOS_CHECK=1 turns this off.
|
||||
ios_check_ok="$DEVENV_STATE/ios-check-ok"
|
||||
if [ -z "''${SKIP_IOS_CHECK:-}" ] && [ -z "$(find "$ios_check_ok" -mtime -1 2>/dev/null)" ]; then
|
||||
if ios-check; then touch "$ios_check_ok"; else echo "Set SKIP_IOS_CHECK=1 to hide this." >&2; 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 "Pixel_5_API34"; then
|
||||
echo no | avdmanager create avd -n "Pixel_5_API34" -k "system-images;android-34;google_apis_playstore;${if pkgs.stdenv.hostPlatform.isAarch64 then "arm64-v8a" else "x86_64"}" -d "pixel_5"
|
||||
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.
|
||||
@@ -140,27 +149,42 @@ in
|
||||
exec = "create-avd";
|
||||
};
|
||||
emulator = {
|
||||
exec = "emulator -avd Pixel_5_API34";
|
||||
exec = "emulator -avd ${avdName} -port 5554";
|
||||
after = ["devenv:processes:createavd@completed"];
|
||||
};
|
||||
# One Metro bundler serves both apps, so iOS doesn't depend on the Android build.
|
||||
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";
|
||||
ready.http.get = { port = 8081; path = "/status"; };
|
||||
};
|
||||
expodev = {
|
||||
# Wait for the emulator to finish booting, otherwise expo tries to start a second copy of the AVD.
|
||||
exec = ''
|
||||
adb -e wait-for-device
|
||||
until [ "$(adb -e shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" = 1 ]; do sleep 2; done
|
||||
bunx expo run:android
|
||||
# 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"];
|
||||
restart = {
|
||||
on = "always";
|
||||
after = [ "devenv:processes:emulator@started" "devenv:processes:metro" ];
|
||||
};
|
||||
};
|
||||
# Reuses the Metro bundler started by expodev.
|
||||
expoios = lib.mkIf pkgs.stdenv.isDarwin {
|
||||
exec = "run-ios";
|
||||
cwd = "./apps/mobile";
|
||||
after = ["devenv:processes:expodev@started"];
|
||||
after = [ "devenv:processes:metro" ];
|
||||
};
|
||||
<@endif>
|
||||
sqlwatcher = {
|
||||
|
||||
@@ -9,5 +9,8 @@
|
||||
"scripts": {
|
||||
"dev": "bun run --filter '*' dev",
|
||||
"build": "bun run --filter '*' build"
|
||||
}
|
||||
}<@if(eq(context.project.mobile,"expo"))>,
|
||||
"overrides": {
|
||||
"react-native-screens": "~4.26.0"
|
||||
}<@endif>
|
||||
}
|
||||
|
||||
@@ -32,9 +32,7 @@ var serveCmd = &cobra.Command{
|
||||
}
|
||||
conf := *config.Conf
|
||||
ctx := context.Background()
|
||||
db_url := "postgres://" + conf.Database.User + ":" + conf.Database.Password + "@" + conf.Database.Host + ":" + strconv.Itoa(conf.Database.Port) + "/" + conf.Database.Name
|
||||
db_url = db_url + "?sslmode=disable"
|
||||
conn, err := pgxpool.New(ctx, db_url)
|
||||
conn, err := pgxpool.New(ctx, conf.Database.URL().String())
|
||||
if err != nil {
|
||||
panic(err.Error())
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ package config
|
||||
import (
|
||||
"github.com/adrg/xdg"
|
||||
"github.com/pelletier/go-toml"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var confPath string
|
||||
@@ -18,7 +21,7 @@ func init() {
|
||||
Server: Server{
|
||||
Host: "127.0.0.1",
|
||||
Port: 8080,
|
||||
FrontendUrls: []string{"http://localhost:5173"},
|
||||
FrontendUrls: []string{"http://localhost:5173", "http://127.0.0.1:5173"},
|
||||
},
|
||||
Database: Database{
|
||||
User: "pp",
|
||||
@@ -75,6 +78,17 @@ type Database struct {
|
||||
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 {
|
||||
Server Server
|
||||
Database Database
|
||||
|
||||
@@ -5,9 +5,6 @@ import (
|
||||
"github.com/amacneil/dbmate/v2/pkg/dbmate"
|
||||
_ "github.com/amacneil/dbmate/v2/pkg/driver/postgres"
|
||||
"log"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var Q *Queries
|
||||
@@ -19,23 +16,7 @@ func RunDbMigrations() error {
|
||||
return err
|
||||
}
|
||||
conf := *config.Conf
|
||||
dbUrl, err := url.Parse(strings.Join([]string{
|
||||
"postgresql://",
|
||||
conf.Database.User,
|
||||
":",
|
||||
conf.Database.Password,
|
||||
"@",
|
||||
conf.Database.Host,
|
||||
":",
|
||||
strconv.Itoa(conf.Database.Port),
|
||||
"/",
|
||||
conf.Database.Name,
|
||||
"?sslmode=disable",
|
||||
}, ""))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db := dbmate.New(dbUrl)
|
||||
db := dbmate.New(conf.Database.URL())
|
||||
db.FS = MigrationsFs
|
||||
db.MigrationsDir = []string{"migrations"}
|
||||
migrations, err := db.FindMigrations()
|
||||
|
||||
@@ -59,20 +59,20 @@ func (srv *TodoServer) ListTodos(ctx context.Context, req *connect.Request[todov
|
||||
Done: BoolPtr(todo.Done.Bool),
|
||||
})
|
||||
}
|
||||
slices.SortFunc(responseTodos, func(a, b *todov1.Todo) int {
|
||||
dateCmp := cmp.Compare(a.GetCreatedAt(), b.GetCreatedAt())
|
||||
return dateCmp
|
||||
})
|
||||
slices.SortFunc(responseTodos, func(a, b *todov1.Todo) int {
|
||||
var boolToInt = func(b bool) int {
|
||||
// Open todos first, each group oldest first. A single total order (SortFunc isn't stable)
|
||||
// keeps rows from swapping places between polls.
|
||||
boolToInt := func(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
} else {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
doneCmp := cmp.Compare(boolToInt(*a.Done), boolToInt(*b.Done))
|
||||
return doneCmp
|
||||
slices.SortFunc(responseTodos, func(a, b *todov1.Todo) int {
|
||||
return cmp.Or(
|
||||
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]{
|
||||
|
||||
Reference in New Issue
Block a user