Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7e7e615c2 | ||
|
|
7fb87fbb96 | ||
|
|
aa647ee869 | ||
|
|
97bda53e9b | ||
|
|
8105f57be9 | ||
|
|
15565b7208 | ||
|
|
d1c46cdee1 | ||
|
|
d33b9c5467 | ||
|
|
df0e78d9ba | ||
|
|
cc596e632f | ||
|
|
7923514c5c |
@@ -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 }}
|
||||||
|
|||||||
@@ -1,30 +1,45 @@
|
|||||||
# 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/)
|
||||||
|
|
||||||
|
Supported mobile frontends:
|
||||||
|
|
||||||
|
- [React Native + Expo](https://expo.dev/)
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
bun create glstack
|
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
|
||||||
|
- **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 or Solid Start)
|
||||||
|
- **`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 +47,39 @@ 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` 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.
|
||||||
|
|
||||||
|
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 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.
|
||||||
|
|
||||||
|
#### 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.
|
||||||
|
|
||||||
|
| 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. 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.
|
||||||
|
|
||||||
## 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.1.4",
|
"@gregorlohaus/tdir": "^0.2.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.1.4", "", { "peerDependencies": { "zod": "^4" }, "bin": { "tdir": "dist/cli.js" } }, "sha512-etrMNRSf04TIxj6qoKBTM+eLA/xmFN0yovFqE5aJLUdq653ceRM9uWW7XB7iSHs89STQ442Q0ouTMaTGxrkIUg=="],
|
"@gregorlohaus/tdir": ["@gregorlohaus/tdir@0.2.0", "", { "peerDependencies": { "zod": "^4" }, "bin": { "tdir": "dist/cli.js" } }, "sha512-LwISQC7iARezu7SQZJk9TV8Sqp8I/fVMssH18lWL1KD/ETpWp2LwwTzm/j9aWcs+Eo5OBQctbqFTafo9Nfue+A=="],
|
||||||
|
|
||||||
"@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=="],
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import * as p from "@clack/prompts";
|
|||||||
import { initRenderer } from '@gregorlohaus/tdir'
|
import { initRenderer } from '@gregorlohaus/tdir'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { readdir, rename } from "node:fs/promises";
|
||||||
|
|
||||||
p.intro(`create-glstack ${process.env.GLSTACK_DEV && 'isDev'}`);
|
p.intro(`create-glstack ${process.env.GLSTACK_DEV && 'isDev'}`);
|
||||||
|
|
||||||
@@ -22,12 +23,37 @@ const project = await p.group(
|
|||||||
await p.select({
|
await p.select({
|
||||||
message: 'Pick a frontend framework.',
|
message: 'Pick a frontend framework.',
|
||||||
options: [
|
options: [
|
||||||
{value: "svelte-kit", label:"SvelteKit"},
|
{ value: "svelte-kit", label: "SvelteKit" },
|
||||||
{value: "solid-start", label:"SolidStart"},
|
{ value: "solid-start", label: "SolidStart" },
|
||||||
{value: "none", label:"None"}
|
{ value: "none", label: "None" }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
,
|
,
|
||||||
|
mobile: async () =>
|
||||||
|
await p.select({
|
||||||
|
message: 'Pick a mobile framework.',
|
||||||
|
options: [
|
||||||
|
{ value: "expo", label: "ReactNative + Expo" },
|
||||||
|
{ value: "none", label: "None" }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
,
|
||||||
|
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`
|
||||||
|
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)) {
|
||||||
|
return "Use reverse-DNS segments of letters and digits, e.g. com.example.app"
|
||||||
|
}
|
||||||
|
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?",
|
||||||
@@ -37,6 +63,9 @@ const project = await p.group(
|
|||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
installDeps: async () => {
|
||||||
|
return await p.confirm({message: "Install dependencies?", })
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onCancel: () => {
|
onCancel: () => {
|
||||||
@@ -53,16 +82,35 @@ 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(),
|
||||||
|
bundleid: z.string()
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
const destDir = path.join("./",project.name);
|
const destDir = path.join("./", project.name);
|
||||||
render(destDir,{project},{reverseMap: true})
|
// `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))
|
||||||
|
}
|
||||||
|
|
||||||
const s = p.spinner();
|
const s = p.spinner();
|
||||||
s.start("Installing dependencies");
|
if (project.installDeps) {
|
||||||
await Bun.$`bun install`.cwd(path.join(destDir)).quiet();
|
s.start("Installing dependencies");
|
||||||
await Bun.$`bun install`.cwd(path.join(destDir,'packages','rpc')).quiet();
|
await Bun.$`bun install`.cwd(path.join(destDir)).quiet();
|
||||||
await Bun.$`bun install`.cwd(path.join(destDir,'apps','web')).quiet();
|
s.stop("Dependencies installed.");
|
||||||
s.stop("Dependencies installed.");
|
}
|
||||||
|
|
||||||
p.outro("You're all set!");
|
p.outro("You're all set!");
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "create-glstack",
|
"name": "create-glstack",
|
||||||
"version": "0.0.6",
|
"version": "0.0.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.2.0",
|
"@clack/prompts": "^1.2.0",
|
||||||
"@gregorlohaus/tdir": "^0.1.4",
|
"@gregorlohaus/tdir": "^0.2.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
|
||||||
@@ -1 +0,0 @@
|
|||||||
node_modules
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "example-basic",
|
"name": "example-bare",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -9,16 +9,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@<@var(context.project.name)>/rpc": "workspace:*",
|
"@<@var(context.project.name)>/rpc": "workspace:*",
|
||||||
"@solidjs/meta": "^0.29.4",
|
|
||||||
"@solidjs/router": "^0.15.0",
|
|
||||||
"@solidjs/start": "2.0.0-alpha.2",
|
"@solidjs/start": "2.0.0-alpha.2",
|
||||||
"@solidjs/vite-plugin-nitro-2": "^0.1.0",
|
"@solidjs/vite-plugin-nitro-2": "^0.1.0",
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
|
||||||
"@tanstack/query-db-collection": "^1.0.35",
|
|
||||||
"@tanstack/solid-db": "^0.2.18",
|
|
||||||
"@tanstack/solid-query": "^5.96.2",
|
|
||||||
"solid-js": "^1.9.5",
|
"solid-js": "^1.9.5",
|
||||||
"tailwindcss": "^4.2.2",
|
|
||||||
"vite": "^7.0.0"
|
"vite": "^7.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,5 +1,61 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: Gordita, Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,58 +1,58 @@
|
|||||||
import { MetaProvider, Title } from "@solidjs/meta";
|
import { createSignal, For } from "solid-js";
|
||||||
import { Router } from "@solidjs/router";
|
import { createRouter, Todo } from "@<@var(context.project.name)>/rpc"
|
||||||
import { FileRoutes } from "@solidjs/start/router";
|
import { createEffect } from 'solid-js'
|
||||||
import { Suspense } from "solid-js";
|
|
||||||
import { createCollection } from '@tanstack/solid-db'
|
|
||||||
import { queryCollectionOptions } from '@tanstack/query-db-collection'
|
|
||||||
import type { Todo, ExtractPayload } from '@<@var(context.project.name)>/rpc'
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
|
||||||
import { getRouter } from "./lib/getRouter"
|
|
||||||
import "./app.css";
|
import "./app.css";
|
||||||
import { TodoCollectionProvider } from "./context/todocollection/provider";
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
|
|
||||||
})
|
|
||||||
const router = getRouter();
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const todosCollection = createCollection(
|
const router = createRouter("http://127.0.0.1:8080")
|
||||||
queryCollectionOptions({
|
const [todos, setTodos] = createSignal<Todo[]>([]);
|
||||||
queryKey: ["todos"],
|
const [todoToCreateTask, setTodoToCreateTask] = createSignal<string>("");
|
||||||
queryFn: async () => {
|
const fetchTodos = () => {
|
||||||
const todos = await router.todos.listTodos({})
|
router.todos.listTodos({}).then((r) => {
|
||||||
return todos.todos as ExtractPayload<Todo>[];
|
setTodos(r.todos)
|
||||||
},
|
|
||||||
queryClient,
|
|
||||||
getKey: (item) => item.id ? item.id : crypto.randomUUID(),
|
|
||||||
onInsert: async ({ transaction }) => {
|
|
||||||
Promise.all(transaction.mutations.map((m) => {
|
|
||||||
router.todos.createTodo({ todo: m.modified })
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
onDelete: async ({ transaction }) => {
|
|
||||||
Promise.all(transaction.mutations.map((m) => {
|
|
||||||
router.todos.deleteTodo({ todo: m.modified })
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
onUpdate: async ({ transaction }) => {
|
|
||||||
Promise.all(transaction.mutations.map((m) => {
|
|
||||||
router.todos.updateTodo({ todo: m.modified })
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
)
|
}
|
||||||
|
createEffect(() => {
|
||||||
|
fetchTodos()
|
||||||
|
})
|
||||||
|
|
||||||
|
const setTodoDone = (id: string, task: string) => {
|
||||||
|
return async (event : Event & { currentTarget: HTMLInputElement }) => {
|
||||||
|
await router.todos.updateTodo({ todo: { id, task, done: event.currentTarget.checked } })
|
||||||
|
fetchTodos()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteTodo = (id: string) => {
|
||||||
|
return async () => {
|
||||||
|
await router.todos.deleteTodo({ todo: { id } })
|
||||||
|
fetchTodos()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createTodo = () => {
|
||||||
|
router.todos.createTodo({ todo: { id: crypto.randomUUID(), task: todoToCreateTask() } }).then((r) => {
|
||||||
|
console.log(r)
|
||||||
|
fetchTodos()
|
||||||
|
}).catch((e) => {
|
||||||
|
console.log(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router
|
<div>
|
||||||
root={props => (
|
<p>Create Todo</p>
|
||||||
<MetaProvider>
|
<input value={todoToCreateTask()} onInput={(e) => setTodoToCreateTask(e.currentTarget.value)} type="text"/>
|
||||||
<QueryClientProvider client={queryClient} >
|
<button onclick={createTodo}> create </button>
|
||||||
<TodoCollectionProvider value={todosCollection}>
|
<For each={todos()}>
|
||||||
<Suspense>{props.children}</Suspense>
|
{(item:Todo) =>
|
||||||
</TodoCollectionProvider>
|
<div>
|
||||||
</QueryClientProvider>
|
{item.task}
|
||||||
</MetaProvider>
|
<input type='checkbox' checked={item.done} onChange={setTodoDone(item.id || "",item.task)}/>
|
||||||
)}
|
<button onclick={deleteTodo(item.id || "")}>delete</button>
|
||||||
>
|
</div>
|
||||||
<FileRoutes/>
|
}
|
||||||
</Router>
|
</For>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import { ExtractPayload, type Todo } from '@<@var(context.project.name)>/rpc'
|
|
||||||
import { createSignal } from 'solid-js';
|
|
||||||
import { useTodoCollection } from '~/context/todocollection/create';
|
|
||||||
|
|
||||||
export const CreateTodo = () => {
|
|
||||||
const [todoState, setTodoState] = createSignal<ExtractPayload<Todo>>({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
task: "",
|
|
||||||
done: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
let inputRef!: HTMLInputElement
|
|
||||||
|
|
||||||
const setTask = () => {
|
|
||||||
const todo = {...todoState()}
|
|
||||||
todo.task = inputRef.value;
|
|
||||||
setTodoState(todo)
|
|
||||||
}
|
|
||||||
|
|
||||||
const todoCollection = useTodoCollection()
|
|
||||||
|
|
||||||
const insertTodo = () => {
|
|
||||||
todoCollection.insert(todoState())
|
|
||||||
setTodoState({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
task: "",
|
|
||||||
done: false
|
|
||||||
})
|
|
||||||
inputRef.value = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<input class="border rounded-md px-3 py-1" ref={inputRef} type='text' onInput={setTask} />
|
|
||||||
<button class="bg-teal-800 text-teal-100 p-1 rounded-md cursor-pointer" onClick={insertTodo}> create </button>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { useLiveQuery } from "@tanstack/solid-db";
|
|
||||||
import { useTodoCollection } from "~/context/todocollection/create";
|
|
||||||
import { Todo } from './Todo'
|
|
||||||
import { For } from 'solid-js'
|
|
||||||
|
|
||||||
export const ListTodos = () => {
|
|
||||||
const todoCollection = useTodoCollection()
|
|
||||||
const data = useLiveQuery((q) =>
|
|
||||||
q.from({todos: todoCollection})
|
|
||||||
)
|
|
||||||
const todos = () => {
|
|
||||||
const items = Array.from(data.state.values());
|
|
||||||
return items.sort((a: any, b: any) => {
|
|
||||||
if (a.done != b.done) return a.done ? -1 : 1
|
|
||||||
if (!a.done && !b.done) {
|
|
||||||
const adate = a.createdAt ? new Date(a.createdAt) : new Date()
|
|
||||||
const bdate = b.createdAt ? new Date(b.createdAt) : new Date()
|
|
||||||
return bdate.getTime() - adate.getTime()
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<For each={todos()} fallback={<div>Loading...</div>}>
|
|
||||||
{(item) => <Todo todo={item} />}
|
|
||||||
</For>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { ExtractPayload, type Todo as RpcTodo } from '@<@var(context.project.name)>/rpc'
|
|
||||||
import { createSignal, type JSX } from 'solid-js';
|
|
||||||
import { useTodoCollection } from '~/context/todocollection/create';
|
|
||||||
|
|
||||||
export const Todo = ({ todo }: { todo: ExtractPayload<RpcTodo> }) => {
|
|
||||||
const [todoState, setTodoState] = createSignal(todo)
|
|
||||||
const todoCollection = useTodoCollection()
|
|
||||||
let commitUpdateTimeoutId: NodeJS.Timeout|null = null;
|
|
||||||
const updateTask: JSX.EventHandlerUnion<HTMLInputElement, Event> = (e) => {
|
|
||||||
if (commitUpdateTimeoutId != null) {
|
|
||||||
clearTimeout(commitUpdateTimeoutId)
|
|
||||||
}
|
|
||||||
const todo = { ...todoState() };
|
|
||||||
todo.task = e.currentTarget.value
|
|
||||||
setTodoState(todo)
|
|
||||||
commitUpdateTimeoutId = setTimeout(() => {
|
|
||||||
todoCollection.update(todoState().id, (draft) => {
|
|
||||||
draft.task = todoState().task
|
|
||||||
})
|
|
||||||
},3000)
|
|
||||||
|
|
||||||
}
|
|
||||||
const updateDone: JSX.EventHandlerUnion<HTMLInputElement, Event> = (e) => {
|
|
||||||
const todo = { ...todoState() }
|
|
||||||
todo.done = e.currentTarget.checked
|
|
||||||
setTodoState(todo)
|
|
||||||
todoCollection.update(todoState().id, (draft) => {
|
|
||||||
draft.done = todoState().done
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const del = () => {
|
|
||||||
todoCollection.delete(todoState().id || "")
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col items-center border rounded-md p-5 gap-2">
|
|
||||||
<input class="text-center border rounded-md px-3 py-1" oninput={updateTask} type="text" value={todoState().task} />
|
|
||||||
<span> {(new Date(todoState().createdAt || "")).toLocaleString()}</span>
|
|
||||||
<span> {(new Date(todoState().updatesAt || "")).toLocaleString()}</span>
|
|
||||||
<input type="checkbox" onchange={updateDone} checked={todoState().done} />
|
|
||||||
<button class="bg-amber-800 text-amber-100 p-1 rounded-md cursor-pointer" onclick={del} class=""> delete </button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { createContext,useContext } from 'solid-js'
|
|
||||||
import { type CollectionImpl, type CollectionLike } from '@tanstack/solid-db'
|
|
||||||
import type { ExtractPayload, Todo } from '@<@var(context.project.name)>/rpc';
|
|
||||||
export type TodoCollection = CollectionImpl<ExtractPayload<Todo>>
|
|
||||||
export const TodoCollectionContext = createContext<TodoCollection>()
|
|
||||||
export const useTodoCollection = () => {
|
|
||||||
const col = useContext(TodoCollectionContext)
|
|
||||||
if (col === undefined) {
|
|
||||||
throw new Error("Todo collection has not been initialized")
|
|
||||||
}
|
|
||||||
return col;
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { JSXElement } from 'solid-js';
|
|
||||||
import { TodoCollection, TodoCollectionContext } from './create'
|
|
||||||
|
|
||||||
export const TodoCollectionProvider = (props : {children:JSXElement, value: TodoCollection}) => {
|
|
||||||
return (
|
|
||||||
<TodoCollectionContext.Provider value={props.value}> {props.children} </TodoCollectionContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { createRouter } from "@<@var(context.project.name)>/rpc"
|
|
||||||
|
|
||||||
const router = createRouter("http://127.0.0.1:8080")
|
|
||||||
|
|
||||||
export const getRouter = () => {
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Title } from "@solidjs/meta";
|
|
||||||
import { HttpStatusCode } from "@solidjs/start";
|
|
||||||
|
|
||||||
export default function NotFound() {
|
|
||||||
return (
|
|
||||||
<main>
|
|
||||||
<Title>Not Found</Title>
|
|
||||||
<HttpStatusCode code={404} />
|
|
||||||
<h1>Page Not Found</h1>
|
|
||||||
<p>
|
|
||||||
Visit{" "}
|
|
||||||
<a href="https://start.solidjs.com" target="_blank">
|
|
||||||
start.solidjs.com
|
|
||||||
</a>{" "}
|
|
||||||
to learn how to build SolidStart apps.
|
|
||||||
</p>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { Title } from "@solidjs/meta";
|
|
||||||
import { CreateTodo } from "~/components/CreateTodo";
|
|
||||||
import { ListTodos } from "~/components/ListTodos"
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
|
||||||
<main class="flex flex-col items-center gap-2 py-5">
|
|
||||||
<Title>Todos</Title>
|
|
||||||
<h1 class="text-5xl"> Todos </h1>
|
|
||||||
<CreateTodo/>
|
|
||||||
<ListTodos/>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { nitroV2Plugin as nitro } from "@solidjs/vite-plugin-nitro-2";
|
import { nitroV2Plugin as nitro } from "@solidjs/vite-plugin-nitro-2";
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
|
||||||
import { solidStart } from "@solidjs/start/config";
|
import { solidStart } from "@solidjs/start/config";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [solidStart(),
|
||||||
solidStart(),
|
nitro()
|
||||||
nitro(),
|
|
||||||
tailwindcss()
|
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,14 +21,6 @@
|
|||||||
"vite": "^8.0.7"
|
"vite": "^8.0.7"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^2.11.0",
|
"@<@var(context.project.name)>/rpc": "workspace:*"
|
||||||
"@connectrpc/connect": "^2.1.1",
|
|
||||||
"@connectrpc/connect-web": "^2.1.1",
|
|
||||||
"@<@var(context.project.name)>/rpc": "workspace:*",
|
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
|
||||||
"@tanstack/query-db-collection": "^1.0.33",
|
|
||||||
"@tanstack/svelte-db": "^0.1.79",
|
|
||||||
"@tanstack/svelte-query": "^6.1.13",
|
|
||||||
"tailwindcss": "^4.3.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
import @tailwindcss;
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { ExtractPayload, Todo } from '@<@var(context.project.name)>/rpc';
|
|
||||||
import { getTodoCollection } from '$lib/todocollectionscontext';
|
|
||||||
|
|
||||||
const todoCollection = getTodoCollection();
|
|
||||||
let todo = $state<ExtractPayload<Todo>>({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
task: '',
|
|
||||||
done: false
|
|
||||||
});
|
|
||||||
|
|
||||||
const insertTodo = () => {
|
|
||||||
todoCollection.insert({ ...todo });
|
|
||||||
todo = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
task: '',
|
|
||||||
done: false
|
|
||||||
};
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<input class="rounded-md border px-3 py-1" type="text" bind:value={todo.task} />
|
|
||||||
<button class="cursor-pointer rounded-md bg-teal-800 p-1 text-teal-100" onclick={insertTodo}>
|
|
||||||
create
|
|
||||||
</button>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { getTodoCollection } from '$lib/todocollectionscontext';
|
|
||||||
import { useLiveQuery } from '@tanstack/svelte-db';
|
|
||||||
import Todo from './Todo.svelte';
|
|
||||||
|
|
||||||
const todoCollection = getTodoCollection();
|
|
||||||
const data = useLiveQuery((q) => q.from({ todos: todoCollection }));
|
|
||||||
const todos = $derived.by(() => {
|
|
||||||
return data.data.toSorted((a, b) => {
|
|
||||||
if (a.done != b.done) {
|
|
||||||
return a.done ? -1 : 1;
|
|
||||||
}
|
|
||||||
if (!a.done && !b.done) {
|
|
||||||
const adate = a.createdAt ? new Date(a.createdAt) : new Date();
|
|
||||||
const bdate = b.createdAt ? new Date(b.createdAt) : new Date();
|
|
||||||
return bdate.getTime() - adate.getTime();
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if data.isLoading}
|
|
||||||
<div>Loading...</div>
|
|
||||||
{:else}
|
|
||||||
{#each todos as todo (todo.id)}
|
|
||||||
<Todo {todo} />
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { ExtractPayload, Todo as RpcTodo } from '@<@var(context.project.name)>/rpc';
|
|
||||||
import { getTodoCollection } from '$lib/todocollectionscontext';
|
|
||||||
|
|
||||||
let { todo }: { todo: ExtractPayload<RpcTodo> } = $props();
|
|
||||||
const getInitialTodo = () => todo;
|
|
||||||
let todoState = $state<ExtractPayload<RpcTodo>>({ ...getInitialTodo() });
|
|
||||||
let commitUpdateTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
const todoCollection = getTodoCollection();
|
|
||||||
|
|
||||||
const updateTask = () => {
|
|
||||||
if (commitUpdateTimeoutId != null) {
|
|
||||||
clearTimeout(commitUpdateTimeoutId);
|
|
||||||
}
|
|
||||||
|
|
||||||
commitUpdateTimeoutId = setTimeout(() => {
|
|
||||||
todoCollection.update(todoState.id, (draft) => {
|
|
||||||
draft.task = todoState.task;
|
|
||||||
});
|
|
||||||
}, 3000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateDone = () => {
|
|
||||||
todoCollection.update(todoState.id, (draft) => {
|
|
||||||
draft.done = todoState.done;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const del = () => {
|
|
||||||
todoCollection.delete(todoState.id || '');
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex flex-col items-center gap-2 rounded-md border p-5">
|
|
||||||
<input
|
|
||||||
class="rounded-md border px-3 py-1 text-center"
|
|
||||||
type="text"
|
|
||||||
bind:value={todoState.task}
|
|
||||||
oninput={updateTask}
|
|
||||||
/>
|
|
||||||
<span>{new Date(todoState.createdAt || '').toLocaleString()}</span>
|
|
||||||
<span>{new Date(todoState.updatesAt || '').toLocaleString()}</span>
|
|
||||||
<input type="checkbox" bind:checked={todoState.done} onchange={updateDone} />
|
|
||||||
<button class="cursor-pointer rounded-md bg-amber-800 p-1 text-amber-100" onclick={del}>
|
|
||||||
delete
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import {createRouter} from '@<@var(context.project.name)>/rpc'
|
|
||||||
|
|
||||||
const router = createRouter("http://127.0.0.1:8080")
|
|
||||||
|
|
||||||
export const getRouter = () => {
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import { createContext } from "svelte";
|
|
||||||
import { type CollectionImpl } from '@tanstack/svelte-db'
|
|
||||||
import type { Todo, ExtractPayload } from "@<@var(context.project.name)>/rpc";
|
|
||||||
export const [getTodoCollection,setTodoCollection] = createContext<CollectionImpl<ExtractPayload<Todo>,string>>()
|
|
||||||
@@ -1,53 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createCollection } from '@tanstack/svelte-db';
|
|
||||||
import { queryCollectionOptions } from '@tanstack/query-db-collection';
|
|
||||||
import { getRouter } from "$lib/getconnectrouter"
|
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import "../app.css"
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/svelte-query';
|
|
||||||
import type { ExtractPayload, Todo } from '@<@var(context.project.name)>/rpc';
|
|
||||||
import { browser } from '$app/environment';
|
|
||||||
import { setTodoCollection } from '$lib/todocollectionscontext';
|
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
const router = getRouter()
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
enabled: browser
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const todosCollection = createCollection(
|
|
||||||
queryCollectionOptions({
|
|
||||||
queryKey: ["todos"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const todos = await router.todos.listTodos({})
|
|
||||||
return todos.todos as ExtractPayload<Todo>[];
|
|
||||||
},
|
|
||||||
queryClient,
|
|
||||||
getKey: (item) => item.id ? item.id : crypto.randomUUID(),
|
|
||||||
onInsert: async ({transaction}) => {
|
|
||||||
Promise.all(transaction.mutations.map((m) => {
|
|
||||||
router.todos.createTodo({ todo: m.modified })
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
onDelete: async ({transaction}) => {
|
|
||||||
Promise.all(transaction.mutations.map((m) => {
|
|
||||||
router.todos.deleteTodo({todo: m.modified})
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
onUpdate: async ({transaction}) => {
|
|
||||||
Promise.all(transaction.mutations.map((m) => {
|
|
||||||
router.todos.updateTodo({todo: m.modified})
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
)
|
|
||||||
setTodoCollection(todosCollection)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<link rel="icon" href={favicon} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
<QueryClientProvider client={queryClient}>
|
{@render children()}
|
||||||
{@render children()}
|
|
||||||
</QueryClientProvider>
|
|
||||||
|
|||||||
@@ -1,11 +1,51 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import CreateTodo from "$lib/components/CreateTodo.svelte";
|
import { createRouter, type Todo } from "@<@var(context.project.name)>/rpc"
|
||||||
import ListTodos from "$lib/components/ListTodos.svelte";
|
import type { ChangeEventHandler } from "svelte/elements";
|
||||||
|
const router = createRouter("http://127.0.0.1:8080");
|
||||||
|
let todos = $state<Todo[]>([])
|
||||||
|
let todoToCreateTask = $state<string>("")
|
||||||
|
const fetchTodos = () => {
|
||||||
|
router.todos.listTodos({}).then((r) => {
|
||||||
|
todos = r.todos;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
$effect(() => {
|
||||||
|
fetchTodos()
|
||||||
|
})
|
||||||
|
|
||||||
|
const setTodoDone = (id: string, task: string) => {
|
||||||
|
return async (event : Event & { currentTarget: HTMLInputElement }) => {
|
||||||
|
await router.todos.updateTodo({ todo: { id, task, done: event.currentTarget.checked } })
|
||||||
|
fetchTodos()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteTodo = (id: string) => {
|
||||||
|
return async () => {
|
||||||
|
await router.todos.deleteTodo({ todo: { id } })
|
||||||
|
fetchTodos()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createTodo = () => {
|
||||||
|
router.todos.createTodo({ todo: { id: crypto.randomUUID(), task: todoToCreateTask } }).then((r) => {
|
||||||
|
console.log(r)
|
||||||
|
fetchTodos()
|
||||||
|
}).catch((e) => {
|
||||||
|
console.log(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="flex flex-col items-center gap-2 py-5">
|
<div>
|
||||||
<title>Todos</title>
|
<h1>Create Todo</h1>
|
||||||
<h1 class="text-5xl"> Todos </h1>
|
<input bind:value={todoToCreateTask} type="text"/>
|
||||||
<CreateTodo/>
|
<button onclick={createTodo}> create </button>
|
||||||
<ListTodos/>
|
{#each todos as todo (todo.id)}
|
||||||
</main>
|
<div>
|
||||||
|
{todo.task}
|
||||||
|
<input type='checkbox' onchange={setTodoDone(todo.id || "",todo.task)}/>
|
||||||
|
<button onclick={deleteTodo(todo.id || "")}>delete</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [sveltekit()]
|
||||||
tailwindcss(),
|
|
||||||
sveltekit(),
|
|
||||||
]
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"expo@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.expo
|
||||||
|
android
|
||||||
|
node_modules
|
||||||
|
assets
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll": "explicit",
|
||||||
|
"source.organizeImports": "explicit",
|
||||||
|
"source.sortMembers": "explicit"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# Expo HAS CHANGED
|
||||||
|
|
||||||
|
Read the exact versioned docs at https://docs.expo.dev/versions/v56.0.0/ before writing any code.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2015-present 650 Industries, Inc. (aka Expo)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
# Native
|
||||||
|
.kotlin/
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
example
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "mobile",
|
||||||
|
"slug": "mobile",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/images/icon.png",
|
||||||
|
"scheme": "mobile",
|
||||||
|
"userInterfaceStyle": "automatic",
|
||||||
|
"ios": {
|
||||||
|
"icon": "./assets/expo.icon",
|
||||||
|
"bundleIdentifier": "<@var(context.project.bundleid)>"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"backgroundColor": "#E6F4FE",
|
||||||
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
|
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||||
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
|
},
|
||||||
|
"predictiveBackGestureEnabled": false,
|
||||||
|
"package": "<@var(context.project.bundleid)>"
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"output": "static",
|
||||||
|
"favicon": "./assets/images/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
[
|
||||||
|
"expo-splash-screen",
|
||||||
|
{
|
||||||
|
"backgroundColor": "#208AEF",
|
||||||
|
"android": {
|
||||||
|
"image": "./assets/images/splash-icon.png",
|
||||||
|
"imageWidth": 76
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expo-font",
|
||||||
|
"expo-image",
|
||||||
|
"expo-status-bar",
|
||||||
|
"expo-web-browser"
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true,
|
||||||
|
"reactCompiler": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="652" height="606" viewBox="0 0 652 606" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M353.554 0H298.446C273.006 0 249.684 14.6347 237.962 37.9539L4.37994 502.646C-1.04325 513.435 -1.45067 526.178 3.2716 537.313L22.6123 582.918C34.6475 611.297 72.5404 614.156 88.4414 587.885L309.863 222.063C313.34 216.317 319.439 212.826 326 212.826C332.561 212.826 338.659 216.317 342.137 222.063L563.559 587.885C579.46 614.156 617.352 611.297 629.388 582.918L648.728 537.313C653.451 526.178 653.043 513.435 647.62 502.646L414.038 37.9539C402.316 14.6347 378.994 0 353.554 0Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 608 B |
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"fill" : {
|
||||||
|
"automatic-gradient" : "extended-srgb:0.00000,0.47843,1.00000,1.00000"
|
||||||
|
},
|
||||||
|
"groups" : [
|
||||||
|
{
|
||||||
|
"layers" : [
|
||||||
|
{
|
||||||
|
"image-name" : "expo-symbol 2.svg",
|
||||||
|
"name" : "expo-symbol 2",
|
||||||
|
"position" : {
|
||||||
|
"scale" : 1,
|
||||||
|
"translation-in-points" : [
|
||||||
|
1.1008400065293245e-05,
|
||||||
|
-16.046875
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"image-name" : "grid.png",
|
||||||
|
"name" : "grid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shadow" : {
|
||||||
|
"kind" : "neutral",
|
||||||
|
"opacity" : 0.5
|
||||||
|
},
|
||||||
|
"translucency" : {
|
||||||
|
"enabled" : true,
|
||||||
|
"value" : 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms" : {
|
||||||
|
"circles" : [
|
||||||
|
"watchOS"
|
||||||
|
],
|
||||||
|
"squares" : "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 780 KiB |
|
After Width: | Height: | Size: 324 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 215 B |
|
After Width: | Height: | Size: 347 B |
|
After Width: | Height: | Size: 468 B |
|
After Width: | Height: | Size: 253 B |
|
After Width: | Height: | Size: 343 B |
|
After Width: | Height: | Size: 479 B |
|
After Width: | Height: | Size: 58 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
// Learn more: https://docs.expo.dev/guides/monorepos/
|
||||||
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const projectRoot = __dirname;
|
||||||
|
const monorepoRoot = path.resolve(projectRoot, '../..');
|
||||||
|
|
||||||
|
const config = getDefaultConfig(projectRoot);
|
||||||
|
|
||||||
|
// Watch the whole monorepo so changes in packages/* trigger rebuilds.
|
||||||
|
config.watchFolders = [monorepoRoot];
|
||||||
|
|
||||||
|
// Resolve modules from the app's node_modules first, then the workspace root.
|
||||||
|
config.resolver.nodeModulesPaths = [
|
||||||
|
path.resolve(projectRoot, 'node_modules'),
|
||||||
|
path.resolve(monorepoRoot, 'node_modules'),
|
||||||
|
];
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "mobile",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@<@var(context.project.name)>/rpc": "workspace:*",
|
||||||
|
"@expo/ui": "~57.0.22",
|
||||||
|
"expo": "~57.0.27",
|
||||||
|
"expo-constants": "~57.0.21",
|
||||||
|
"expo-crypto": "~57.0.3",
|
||||||
|
"expo-dev-client": "~57.0.19",
|
||||||
|
"expo-device": "~57.0.2",
|
||||||
|
"expo-font": "~57.0.4",
|
||||||
|
"expo-glass-effect": "~57.0.4",
|
||||||
|
"expo-image": "~57.0.5",
|
||||||
|
"expo-linking": "~57.0.12",
|
||||||
|
"expo-router": "~57.0.25",
|
||||||
|
"expo-splash-screen": "~57.0.9",
|
||||||
|
"expo-status-bar": "~57.0.1",
|
||||||
|
"expo-symbols": "~57.0.3",
|
||||||
|
"expo-system-ui": "~57.0.4",
|
||||||
|
"expo-web-browser": "~57.0.3",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"react-native": "0.86.3",
|
||||||
|
"react-native-gesture-handler": "~2.32.0",
|
||||||
|
"react-native-reanimated": "4.5.1",
|
||||||
|
"react-native-safe-area-context": "~5.7.0",
|
||||||
|
"react-native-screens": "~4.26.0",
|
||||||
|
"react-native-web": "~0.21.0",
|
||||||
|
"react-native-worklets": "0.10.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "~19.2.2",
|
||||||
|
"typescript": "~6.0.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
|
"android": "expo run:android",
|
||||||
|
"ios": "expo run:ios",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"lint": "expo lint"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return <Stack screenOptions={{headerShown:false}} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { createRouter, Todo } from "@<@var(context.project.name)>/rpc"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
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 { 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"
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
const router = createRouter(apiUrl)
|
||||||
|
const [todos, setTodos] = useState<Array<Todo>>([])
|
||||||
|
const todoToCreateTask = useNativeState<string>("")
|
||||||
|
const colorScheme = useColorScheme()
|
||||||
|
const fetchTodos = () => {
|
||||||
|
router.todos.listTodos({}).then((r) => {
|
||||||
|
setTodos(r.todos)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTodos();
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setTodoDone = (id: string, task: string) => {
|
||||||
|
return (done: boolean) => {
|
||||||
|
router.todos.updateTodo({ todo: { id, task, done } }).then(fetchTodos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteTodo = (id: string) => {
|
||||||
|
return () => {
|
||||||
|
router.todos.deleteTodo({ todo: { id } }).then(fetchTodos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createTodo = () => {
|
||||||
|
router.todos.createTodo({ todo: { id: Crypto.randomUUID(), task: todoToCreateTask.value } }).then((r) => {
|
||||||
|
console.log(r)
|
||||||
|
fetchTodos()
|
||||||
|
}).catch((e) => {
|
||||||
|
console.log(e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTodoToCreateTask = (task: string) => {
|
||||||
|
todoToCreateTask.value = task
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={{ flex: 1 }}>
|
||||||
|
<Host matchContents={{ vertical: true }}>
|
||||||
|
<Column alignment="center" modifiers={[fillMaxWidth()]}>
|
||||||
|
<Text textStyle={{fontSize:30}}> Create Todo </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} />
|
||||||
|
</Column>
|
||||||
|
</Host>
|
||||||
|
<ScrollView>
|
||||||
|
{
|
||||||
|
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)]}>
|
||||||
|
<Column alignment="start" modifiers={[weight(1)]}>
|
||||||
|
<Text>{todo.task}</Text>
|
||||||
|
</Column>
|
||||||
|
<Column alignment="center">
|
||||||
|
<Checkbox value={todo.done || false} onValueChange={setTodoDone(todo.id || "", todo.task)} />
|
||||||
|
</Column>
|
||||||
|
<Column alignment="end">
|
||||||
|
<Button modifiers={[controlSize('regular')]} label="delete" onPress={deleteTodo(todo.id || "")}/>
|
||||||
|
</Column>
|
||||||
|
</Row>
|
||||||
|
</AnimatedRow>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "expo/tsconfig.base",
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
],
|
||||||
|
"@/assets/*": [
|
||||||
|
"./assets/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".expo/types/**/*.ts",
|
||||||
|
"expo-env.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
{ pkgs, lib, config, inputs, ... }:
|
{ pkgs, lib, config, inputs, ... }:
|
||||||
|
<@if(eq(context.project.mobile,"expo"))>
|
||||||
|
let
|
||||||
|
# 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
|
||||||
@@ -14,7 +23,75 @@
|
|||||||
pkgs.protoc-gen-es
|
pkgs.protoc-gen-es
|
||||||
pkgs.cobra-cli
|
pkgs.cobra-cli
|
||||||
];
|
];
|
||||||
|
<@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
|
||||||
|
reactNative.enable = true;
|
||||||
|
android-studio = {
|
||||||
|
enable = false;
|
||||||
|
};
|
||||||
|
ndk = {
|
||||||
|
enable = true;
|
||||||
|
version = [ "27.1.12297006" ];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
# 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
|
||||||
|
'' + lib.optionalString pkgs.stdenv.isDarwin ''
|
||||||
|
ios-check || true
|
||||||
|
'';
|
||||||
|
# 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"
|
||||||
|
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>
|
||||||
languages.go.enable = true;
|
languages.go.enable = true;
|
||||||
languages.typescript.enable = true;
|
languages.typescript.enable = true;
|
||||||
services.postgres = {
|
services.postgres = {
|
||||||
@@ -29,30 +106,72 @@
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
processes = {
|
processes = {
|
||||||
|
<@if(neq(context.project.frontend,"none"))>
|
||||||
|
bundev = {
|
||||||
|
exec = "bun dev";
|
||||||
|
cwd = "./apps/web";
|
||||||
|
after= ["devenv:processes:air@started"];
|
||||||
|
};
|
||||||
|
<@endif>
|
||||||
air = {
|
air = {
|
||||||
exec = "air";
|
exec = "air";
|
||||||
cwd = "./services/api";
|
cwd = "./services/api";
|
||||||
after = ["devenv:processes:postgres"];
|
after = ["devenv:processes:postgres"];
|
||||||
};
|
};
|
||||||
protowatcher = {
|
protowatcher = {
|
||||||
exec = "watchexec -r -e proto buf generate";
|
exec = "buf generate";
|
||||||
cwd = "./packages/proto";
|
cwd = "./packages/proto";
|
||||||
after= ["devenv:processes:air@started"];
|
watch = {
|
||||||
|
paths = [ ./packages/proto ];
|
||||||
|
extensions = [ "proto" ];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
protojswatcher = {
|
protojswatcher = {
|
||||||
exec = "watchexec -e js,ts -w ./packages/rpc/src -r bun run ./scripts/gen-rpc-index.ts";
|
exec = "bun run ./scripts/gen-rpc-index.ts";
|
||||||
cwd = "./";
|
cwd = "./";
|
||||||
after= ["devenv:processes:protowatcher@started"];
|
watch = {
|
||||||
|
paths = [ ./packages/rpc/src ];
|
||||||
|
extensions = ["js" "ts"];
|
||||||
};
|
};
|
||||||
|
after= ["devenv:processes:protowatcher@completed"];
|
||||||
|
};
|
||||||
|
<@if(eq(context.project.mobile,"expo"))>
|
||||||
|
createavd = {
|
||||||
|
exec = "create-avd";
|
||||||
|
};
|
||||||
|
emulator = {
|
||||||
|
exec = "emulator -avd Pixel_5_API34";
|
||||||
|
after = ["devenv:processes:createavd@completed"];
|
||||||
|
};
|
||||||
|
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
|
||||||
|
'';
|
||||||
|
cwd = "./apps/mobile";
|
||||||
|
after= ["devenv:processes:emulator@started"];
|
||||||
|
restart = {
|
||||||
|
on = "always";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
# Reuses the Metro bundler started by expodev.
|
||||||
|
expoios = lib.mkIf pkgs.stdenv.isDarwin {
|
||||||
|
exec = "run-ios";
|
||||||
|
cwd = "./apps/mobile";
|
||||||
|
after = ["devenv:processes:expodev@started"];
|
||||||
|
};
|
||||||
|
<@endif>
|
||||||
sqlwatcher = {
|
sqlwatcher = {
|
||||||
exec = "watchexec -w ./db/migrations -w ./db/query -r -e sql sqlc generate";
|
exec = "sqlc generate";
|
||||||
|
watch = {
|
||||||
|
paths = [ ./db/migrations ./db/query ];
|
||||||
|
extensions = ["sql" "sqlc"];
|
||||||
|
};
|
||||||
cwd = "./services/api";
|
cwd = "./services/api";
|
||||||
after= ["devenv:processes:air@started"];
|
after= ["devenv:processes:air@started"];
|
||||||
};
|
};
|
||||||
bundev = {
|
|
||||||
exec = "bun dev";
|
|
||||||
cwd = "./apps/web";
|
|
||||||
after= ["devenv:processes:air@started"];
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
|
<@if(eq(context.project.mobile,"expo"))>
|
||||||
|
nixpkgs:
|
||||||
|
allowUnfree: true
|
||||||
|
<@endif>
|
||||||
inputs:
|
inputs:
|
||||||
nixpkgs:
|
nixpkgs:
|
||||||
url: github:cachix/devenv-nixpkgs/rolling
|
url: github:cachix/devenv-nixpkgs/rolling
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*"
|
"packages/*",
|
||||||
|
"scripts"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --filter '*' dev",
|
"dev": "bun run --filter '*' dev",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ func RunDbMigrations() error {
|
|||||||
strconv.Itoa(conf.Database.Port),
|
strconv.Itoa(conf.Database.Port),
|
||||||
"/",
|
"/",
|
||||||
conf.Database.Name,
|
conf.Database.Name,
|
||||||
|
"?sslmode=disable",
|
||||||
}, ""))
|
}, ""))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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=
|
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
package todo
|
package todo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
"connectrpc.com/validate"
|
"connectrpc.com/validate"
|
||||||
"context"
|
|
||||||
"<@var(context.project.goprefix)>/<@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)>/<@var(context.project.name)>/utils"
|
. "<@var(context.project.goprefix)>/<@var(context.project.name)>/utils"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type TodoServer struct{}
|
type TodoServer struct{}
|
||||||
@@ -46,9 +49,9 @@ func (srv *TodoServer) ListTodos(ctx context.Context, req *connect.Request[todov
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
reponseTodos := []*todov1.Todo{}
|
responseTodos := []*todov1.Todo{}
|
||||||
for _, todo := range todos {
|
for _, todo := range todos {
|
||||||
reponseTodos = append(reponseTodos, &todov1.Todo{
|
responseTodos = append(responseTodos, &todov1.Todo{
|
||||||
Id: StrPtr(todo.ID.String()),
|
Id: StrPtr(todo.ID.String()),
|
||||||
Task: todo.Task,
|
Task: todo.Task,
|
||||||
CreatedAt: StrPtr(todo.CreatedAt.Time.Format(time.RFC3339)),
|
CreatedAt: StrPtr(todo.CreatedAt.Time.Format(time.RFC3339)),
|
||||||
@@ -56,10 +59,25 @@ 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 {
|
||||||
|
dateCmp := cmp.Compare(a.GetCreatedAt(), b.GetCreatedAt())
|
||||||
|
return dateCmp
|
||||||
|
})
|
||||||
|
slices.SortFunc(responseTodos, func(a, b *todov1.Todo) int {
|
||||||
|
var boolToInt = func(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
} else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doneCmp := cmp.Compare(boolToInt(*a.Done), boolToInt(*b.Done))
|
||||||
|
return doneCmp
|
||||||
|
})
|
||||||
|
|
||||||
return &connect.Response[todov1.ListTodosResponse]{
|
return &connect.Response[todov1.ListTodosResponse]{
|
||||||
Msg: &todov1.ListTodosResponse{
|
Msg: &todov1.ListTodosResponse{
|
||||||
Todos: reponseTodos,
|
Todos: responseTodos,
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||