Compare commits
1
Commits
6afe1f7b67
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fab51878f |
@@ -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
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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:
|
Supported mobile frontends:
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ bun create glstack
|
|||||||
The CLI asks for:
|
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
|
- **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 or none
|
- **Web frontend** -- SvelteKit, Solid Start, Nuxt or none
|
||||||
- **Mobile frontend** -- React Native + Expo 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`
|
- **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>`
|
- **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:
|
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`** -- 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
|
- **`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
|
||||||
@@ -64,7 +65,7 @@ The app reaches the API at `127.0.0.1:8080` everywhere: the iOS simulator shares
|
|||||||
|
|
||||||
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 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 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
|
#### iOS on macOS
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ CocoaPods comes from Nix. The iOS build uses `/Applications/Xcode.app` (or the X
|
|||||||
|
|
||||||
### 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. 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
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|
||||||
|
|||||||
@@ -38,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" }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
+2
-5
@@ -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"
|
||||||
|
|||||||
@@ -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,7 +1,10 @@
|
|||||||
import { createSignal, For, onCleanup, onMount, Show } 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 "./app.css";
|
import "./app.css";
|
||||||
|
|
||||||
|
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.
|
// Changes made elsewhere (another tab, the mobile app) show up within this interval.
|
||||||
const syncIntervalMs = 2000
|
const syncIntervalMs = 2000
|
||||||
@@ -10,11 +13,25 @@ export default function App() {
|
|||||||
const [todos, setTodos] = createSignal<Todo[]>([]);
|
const [todos, setTodos] = createSignal<Todo[]>([]);
|
||||||
const [offline, setOffline] = createSignal(false);
|
const [offline, setOffline] = createSignal(false);
|
||||||
const [todoToCreateTask, setTodoToCreateTask] = createSignal<string>("");
|
const [todoToCreateTask, setTodoToCreateTask] = createSignal<string>("");
|
||||||
|
|
||||||
|
// 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 }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const fetchTodos = () => {
|
const fetchTodos = () => {
|
||||||
router.todos.listTodos({}).then((r) => {
|
router.todos.listTodos({}).then((r) => {
|
||||||
setOffline(false)
|
setOffline(false)
|
||||||
// Polling returns the same list most of the time; keep the old array so <For> keeps its rows.
|
// Polling returns the same list most of the time; skip re-rendering it.
|
||||||
if (JSON.stringify(todos()) !== JSON.stringify(r.todos)) setTodos(r.todos)
|
if (JSON.stringify(todos()) !== JSON.stringify(r.todos)) showTodos(r.todos)
|
||||||
}).catch(() => setOffline(true))
|
}).catch(() => setOffline(true))
|
||||||
}
|
}
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -30,7 +47,8 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deleteTodo = (id: string) => {
|
const deleteTodo = (id: string) => {
|
||||||
return () => {
|
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))
|
router.todos.deleteTodo({ todo: { id } }).then(fetchTodos, () => setOffline(true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,20 +63,22 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<main>
|
||||||
<p>Create Todo</p>
|
<h1>Create Todo</h1>
|
||||||
<Show when={offline()}><p style={{ color: "#d32f2f" }}>Can't reach the API, retrying…</p></Show>
|
<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?"/>
|
<input value={todoToCreateTask()} onInput={(e) => setTodoToCreateTask(e.currentTarget.value)} type="text" placeholder="What needs to be done?"/>
|
||||||
<button onclick={createTodo}> create </button>
|
<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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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">
|
<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>
|
||||||
|
|||||||
+40
-10
@@ -1,16 +1,35 @@
|
|||||||
<script lang="ts">
|
<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 { createRouter, type Todo } from "@<@var(context.project.name)>/rpc"
|
||||||
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.
|
// Changes made elsewhere (another tab, the mobile app) show up within this interval.
|
||||||
const syncIntervalMs = 2000
|
const syncIntervalMs = 2000
|
||||||
let todos = $state<Todo[]>([])
|
let todos = $state<Todo[]>([])
|
||||||
let offline = $state(false)
|
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) => {
|
||||||
offline = false
|
offline = false
|
||||||
// Polling returns the same list most of the time; skip re-rendering it.
|
// Polling returns the same list most of the time; skip re-rendering it.
|
||||||
if (JSON.stringify($state.snapshot(todos)) !== JSON.stringify(r.todos)) todos = r.todos
|
if (JSON.stringify($state.snapshot(todos)) !== JSON.stringify(r.todos)) showTodos(r.todos)
|
||||||
}).catch(() => offline = true)
|
}).catch(() => offline = true)
|
||||||
}
|
}
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -26,7 +45,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deleteTodo = (id: string) => {
|
const deleteTodo = (id: string) => {
|
||||||
return () => {
|
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)
|
router.todos.deleteTodo({ todo: { id } }).then(fetchTodos, () => offline = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,16 +61,26 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<main>
|
||||||
<h1>Create Todo</h1>
|
<h1>Create Todo</h1>
|
||||||
{#if offline}<p style="color: #d32f2f">Can't reach the API, retrying…</p>{/if}
|
{#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?"/>
|
<input bind:value={todoToCreateTask} type="text" placeholder="What needs to be done?"/>
|
||||||
<button onclick={createTodo}> create </button>
|
<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' checked={todo.done} 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()
|
||||||
|
})
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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]{
|
||||||
|
|||||||
Reference in New Issue
Block a user