simple POC
All checks were successful
build / windows (push) Successful in 5m37s
build / linux (push) Successful in 4m20s

This commit is contained in:
schreifuchs 2025-03-10 11:25:38 +01:00
parent 037b593c6b
commit b107b926a2
17 changed files with 364 additions and 210 deletions

39
app.go
View File

@ -3,19 +3,21 @@ package main
import (
"context"
"fmt"
"tichu-counter/model"
"github.com/gen2brain/beeep"
"gorm.io/gorm"
)
// App struct
type App struct {
ctx context.Context
db *gorm.DB
}
// NewApp creates a new App application struct
func NewApp() *App {
func NewApp(db *gorm.DB) *App {
return &App{}
return &App{db: db}
}
// startup is called when the app starts. The context is saved
@ -23,13 +25,36 @@ func NewApp() *App {
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
err := beeep.Notify("Hello", "World", "")
if err != nil {
fmt.Println(err)
}
}
// Greet returns a greeting for the given name
func (a *App) Greet(name string) string {
return fmt.Sprintf("Hello %s, It's show time!", name)
}
func (a *App) GetLastGameID() uint {
var g model.Game
a.db.Order("updated_at DESC").First(&g)
return g.ID
}
func (a *App) GetGame(id uint) (game model.Game) {
a.db.Preload("Steps").First(&game, id)
game.SumPoints()
return
}
func (a *App) GetGames() (games []model.Game) {
a.db.Find(&games)
return
}
func (a *App) NewGame() (id uint) {
game := model.Game{
Steps: []model.Step{},
}
a.db.Save(&game)
return game.ID
}
func (a *App) SaveStep(s *model.Step) {
a.db.Save(s)
}

View File

@ -1,15 +1,34 @@
<script lang="ts">
import "./app.css";
import { Router, Route, Link, navigate } from "svelte-routing";
import Things from "./routes/Things.svelte";
import { GetGame, GetGames, NewGame } from "../wailsjs/go/main/App";
import "./app.css";
import { Navbar, DarkMode } from "flowbite-svelte";
import { HomeOutline } from "flowbite-svelte-icons";
import Thing from "./routes/Thing.svelte";
import {
Navbar,
DarkMode,
Button,
Dropdown,
DropdownItem,
Heading,
DropdownDivider,
} from "flowbite-svelte";
import { ChevronDownOutline, HomeOutline } from "flowbite-svelte-icons";
import Game from "./routes/Game.svelte";
import { model } from "../wailsjs/go/models";
import { onMount } from "svelte";
let games: model.Game[] = $state([]);
let game: model.Game | null = $state(null);
let url: string = $state("/");
function update() {
GetGames().then((gs) => (games = gs));
if (game) {
GetGame(game.ID).then((g) => (game = g));
}
}
$effect(() => {
console.log(url);
});
onMount(update);
</script>
<div
@ -17,22 +36,55 @@
>
<Router bind:url>
<Navbar class="border-b">
<button
class="grid grid-cols-3 items-center"
onclick={() => navigate("/")}
<!-- <button -->
<!-- class="grid grid-cols-3 items-center" -->
<!-- onclick={() => navigate("/")} -->
<!-- > -->
<!-- <HomeOutline /> -->
<!-- <span class="col-span-2">HOME</span> -->
<!-- </button> -->
<Button>
{#if game === null}
Select a game
{:else}
{game.CreatedAt}
{/if}
<ChevronDownOutline
class="w-6 h-6 ms-2 text-white dark:text-white"
/></Button
>
<HomeOutline />
<span class="col-span-2">HOME</span>
</button>
<Dropdown>
{#each games as g}
<DropdownItem onclick={() => GetGame(g.ID).then((g) => (game = g))}
>{g.CreatedAt}</DropdownItem
>
{/each}
<DropdownDivider />
<DropdownItem
onclick={() =>
NewGame().then((id) =>
GetGame(id).then((g) => {
game = g;
update();
}),
)}>New Game</DropdownItem
>
</Dropdown>
<DarkMode />
</Navbar>
<main
class="size-full max-h-full max-w-full overflow-y-scroll overflow-x-clip"
>
<Route path="/"><Things /></Route>
<Route path="/things/:id" let:params>
<Thing thingID={parseInt(params.id)} />
<Route path="/">
{#if game !== null}
<Game gameId={game.ID} />
{:else}
<Heading class="m-5">Please select a game</Heading>
{/if}
</Route>
<!-- <Route path="/things/:id" let:params> -->
<!-- <Thing thingID={parseInt(params.id)} /> -->
<!-- </Route> -->
</main>
</Router>
</div>

View File

@ -0,0 +1,38 @@
<script lang="ts">
import { Button, ButtonGroup } from "flowbite-svelte";
let { value = $bindable() }: { value: number } = $props();
const active: "primary" = "primary";
const passive: "yellow" | "none" = "none";
</script>
<div class="flex flex-col">
<Button
class="rounded-none rounded-t-lg"
color={value === 200 ? active : passive}
onclick={() => (value = 200)}>200</Button
>
<Button
class="rounded-none"
color={value === 100 ? active : passive}
onclick={() => (value = 100)}
>
100</Button
>
<Button
class="rounded-none"
color={value === 0 ? active : passive}
onclick={() => (value = 0)}>0</Button
>
<Button
class="rounded-none"
color={value === -100 ? active : passive}
onclick={() => (value = -100)}>-100</Button
>
<Button
class="rounded-none rounded-b-lg"
color={value === -200 ? active : passive}
onclick={() => (value = -200)}>-200</Button
>
</div>

View File

@ -0,0 +1,77 @@
<script lang="ts">
import { Button, Heading, P, Range } from "flowbite-svelte";
import { onMount } from "svelte";
import { GetGame, SaveStep } from "../../wailsjs/go/main/App";
import { model } from "../../wailsjs/go/models";
import { derived } from "svelte/store";
import TichuSelect from "@/components/TichuSelect.svelte";
let { gameId }: { gameId: number } = $props();
let game: model.Game = $state(new model.Game());
let rawPoints: number = $state(50);
let pointsTeamA = $derived.by(() => {
if (rawPoints == -30) {
return 200;
}
if (rawPoints == 130) {
return 0;
}
return 100 - rawPoints;
});
let pointsTeamB = $derived.by(() => {
if (rawPoints == -30) {
return 0;
}
if (rawPoints == 130) {
return 200;
}
return rawPoints;
});
let adderTeamA = $state(0);
let adderTeamB = $state(0);
function update() {
GetGame(gameId).then((g) => (game = g));
}
$effect(update);
</script>
<div class="grid grid-cols-2 gap-5 m-5">
<Heading class="text-center">{game.TeamA}</Heading>
<Heading class="text-center">{game.TeamB}</Heading>
<div class="col-span-2 flex gap-5 items-center">
<P>{pointsTeamA}</P>
<Range
id="range-minmax"
min="-30"
max="130"
step="5"
bind:value={rawPoints}
/>
<P>{pointsTeamB}</P>
</div>
<TichuSelect bind:value={adderTeamA} />
<TichuSelect bind:value={adderTeamB} />
<div class="col-span-2 flex gap-5 items-center justify-center">
<Button
onclick={() => {
let step = new model.Step();
step.GameID = gameId;
step.PointsTeamA = pointsTeamA;
step.AdderTeamA = adderTeamA;
step.PointsTeamB = pointsTeamB;
step.AdderTeamB = adderTeamB;
SaveStep(step).then(() => {
update();
rawPoints = 50;
adderTeamA = 0;
adderTeamB = 0;
});
}}>Save</Button
>
</div>
</div>

View File

@ -1,26 +0,0 @@
<script lang="ts">
import { GetThings } from "../../wailsjs/go/things/Service";
import { model } from "../../wailsjs/go/models";
import { onMount } from "svelte";
import { Heading } from "flowbite-svelte";
let { thingID }: { thingID: number } = $props();
let thing: model.Thing = $state(new model.Thing());
function update() {
GetThings().then((ts) => {
ts.forEach((t) => {
if (t.ID === thingID) {
thing = t;
}
});
});
}
onMount(update);
</script>
<div class="m-5">
<Heading>
{thing.Name}
</Heading>
</div>

View File

@ -1,77 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import {
GetThings,
DeleteThing,
NewThing,
} from "../../wailsjs/go/things/Service";
import { model } from "../../wailsjs/go/models";
import {
Label,
Input,
Button,
Table,
TableHead,
TableHeadCell,
TableBody,
TableBodyRow,
TableBodyCell,
} from "flowbite-svelte";
import { navigate } from "svelte-routing";
let name: string = $state();
let thingsList: model.Thing[] = $state([]);
function update() {
GetThings().then((ts) => {
thingsList = ts;
});
}
function submit(e: Event) {
e.preventDefault();
NewThing(name).then(update);
name = "";
}
onMount(update);
</script>
<form class="max-w-96 m-5 grid-cols-1 gap-10" onsubmit={submit}>
<div class="m-5">
<Label for="first_name" class="mb-2">First name</Label>
<Input type="text" placeholder="John" bind:value={name} required />
</div>
<div class="m-5">
<Button type="submit">Submit</Button>
</div>
</form>
<Table>
<TableHead>
<TableHeadCell>ID</TableHeadCell>
<TableHeadCell>Name</TableHeadCell>
<TableHeadCell>View</TableHeadCell>
<TableHeadCell>Delete</TableHeadCell>
</TableHead>
<TableBody>
{#each thingsList as t}
<TableBodyRow>
<TableBodyCell>
{t.ID}
</TableBodyCell>
<TableBodyCell>
{t.Name}
</TableBodyCell>
<TableBodyCell>
<Button onclick={() => navigate(`/things/${t.ID}`)}>View</Button>
</TableBodyCell>
<TableBodyCell>
<Button onclick={() => DeleteThing(t.ID).then(update)}>Delete</Button>
</TableBodyCell>
</TableBodyRow>
{/each}
</TableBody>
</Table>

View File

@ -1,4 +1,15 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {model} from '../models';
export function GetGame(arg1:number):Promise<model.Game>;
export function GetGames():Promise<Array<model.Game>>;
export function GetLastGameID():Promise<number>;
export function Greet(arg1:string):Promise<string>;
export function NewGame():Promise<number>;
export function SaveStep(arg1:model.Step):Promise<void>;

View File

@ -2,6 +2,26 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function GetGame(arg1) {
return window['go']['main']['App']['GetGame'](arg1);
}
export function GetGames() {
return window['go']['main']['App']['GetGames']();
}
export function GetLastGameID() {
return window['go']['main']['App']['GetLastGameID']();
}
export function Greet(arg1) {
return window['go']['main']['App']['Greet'](arg1);
}
export function NewGame() {
return window['go']['main']['App']['NewGame']();
}
export function SaveStep(arg1) {
return window['go']['main']['App']['SaveStep'](arg1);
}

View File

@ -1,17 +1,99 @@
export namespace model {
export class Thing {
export class Step {
ID: number;
Name: string;
// Go type: time
CreatedAt: any;
// Go type: time
UpdatedAt: any;
// Go type: gorm
DeletedAt: any;
GameID: number;
Game: Game;
PointsTeamA: number;
AdderTeamA: number;
PointsTeamB: number;
AdderTeamB: number;
static createFrom(source: any = {}) {
return new Thing(source);
return new Step(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.ID = source["ID"];
this.Name = source["Name"];
this.CreatedAt = this.convertValues(source["CreatedAt"], null);
this.UpdatedAt = this.convertValues(source["UpdatedAt"], null);
this.DeletedAt = this.convertValues(source["DeletedAt"], null);
this.GameID = source["GameID"];
this.Game = this.convertValues(source["Game"], Game);
this.PointsTeamA = source["PointsTeamA"];
this.AdderTeamA = source["AdderTeamA"];
this.PointsTeamB = source["PointsTeamB"];
this.AdderTeamB = source["AdderTeamB"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class Game {
ID: number;
// Go type: time
CreatedAt: any;
// Go type: time
UpdatedAt: any;
// Go type: gorm
DeletedAt: any;
TeamA: number;
TeamB: number;
Steps: Step[];
static createFrom(source: any = {}) {
return new Game(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.ID = source["ID"];
this.CreatedAt = this.convertValues(source["CreatedAt"], null);
this.UpdatedAt = this.convertValues(source["UpdatedAt"], null);
this.DeletedAt = this.convertValues(source["DeletedAt"], null);
this.TeamA = source["TeamA"];
this.TeamB = source["TeamB"];
this.Steps = this.convertValues(source["Steps"], Step);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}

View File

@ -1,9 +0,0 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {model} from '../models';
export function DeleteThing(arg1:number):Promise<void>;
export function GetThings():Promise<Array<model.Thing>>;
export function NewThing(arg1:string):Promise<void>;

View File

@ -1,15 +0,0 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function DeleteThing(arg1) {
return window['go']['things']['Service']['DeleteThing'](arg1);
}
export function GetThings() {
return window['go']['things']['Service']['GetThings']();
}
export function NewThing(arg1) {
return window['go']['things']['Service']['NewThing'](arg1);
}

6
go.mod
View File

@ -1,9 +1,8 @@
module wails-template
module tichu-counter
go 1.24.0
require (
github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4
github.com/wailsapp/wails/v2 v2.10.1
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
@ -12,7 +11,6 @@ require (
require (
github.com/bep/debounce v1.2.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
@ -27,12 +25,10 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect

8
go.sum
View File

@ -2,12 +2,8 @@ github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4 h1:ygs9POGDQpQGLJPlq4+0LBUmMBNox1N4JSpw+OETcvI=
github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4/go.mod h1:0W7dI87PvXJ1Sjs0QPvWXKcQmNERY77e8l7GFhZB/s4=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE=
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@ -41,8 +37,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ=
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@ -56,8 +50,6 @@ github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk=
github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=

View File

@ -2,8 +2,7 @@ package main
import (
"embed"
"wails-template/model"
"wails-template/things"
"tichu-counter/model"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
@ -15,13 +14,12 @@ var assets embed.FS
func main() {
// Create an instance of the app structure
app := NewApp()
db := model.InitDB()
things := &things.Service{DB: db}
app := NewApp(db)
// Create application with options
err := wails.Run(&options.App{
Title: "wails-template",
Title: "tichu-counter",
Width: 1024,
Height: 768,
AssetServer: &assetserver.Options{
@ -31,7 +29,6 @@ func main() {
OnStartup: app.startup,
Bind: []interface{}{
app,
things,
},
})

View File

@ -9,9 +9,33 @@ import (
"gorm.io/gorm"
)
type Thing struct {
ID int
Name string
type Game struct {
gorm.Model
TeamA int `gorm:"-"`
TeamB int `gorm:"-"`
Steps []Step `gorm:"foreignKey:GameID"`
}
func (g *Game) SumPoints() {
g.TeamA = 0
g.TeamB = 0
for _, s := range g.Steps {
g.TeamA += s.PointsTeamA + s.AdderTeamA
g.TeamB += s.PointsTeamB + s.AdderTeamB
}
}
type Step struct {
gorm.Model
GameID uint
Game Game `gorm:"foreignKey:GameID"`
PointsTeamA int
AdderTeamA int
PointsTeamB int
AdderTeamB int
}
func InitDB() *gorm.DB {
@ -19,10 +43,10 @@ func InitDB() *gorm.DB {
if err != nil {
panic(err)
}
db, err := gorm.Open(sqlite.Open(path.Join(home, "things.db")))
db, err := gorm.Open(sqlite.Open(path.Join(home, "tichu.db")))
if err != nil {
log.Panic(err)
}
db.AutoMigrate(&Thing{})
db.AutoMigrate(Game{}, Step{})
return db
}

View File

@ -1,33 +0,0 @@
package things
import (
"log"
"wails-template/model"
"gorm.io/gorm"
)
type Service struct {
DB *gorm.DB
}
func (s *Service) NewThing(name string) {
if err := s.DB.Save(&model.Thing{Name: name}).Error; err != nil {
log.Fatal(err)
}
print(name)
}
func (s *Service) GetThings() (things []model.Thing) {
if err := s.DB.Find(&things).Error; err != nil {
log.Fatal(err)
}
return things
}
func (s *Service) DeleteThing(id int) {
if err := s.DB.Delete(model.Thing{}, id).Error; err != nil {
log.Fatal(err)
}
}

View File

@ -1,7 +1,7 @@
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "wails-template",
"outputfilename": "wails-template",
"name": "tichu-counter",
"outputfilename": "tichu-counter",
"frontend:install": "pnpm install",
"frontend:build": "pnpm run build",
"frontend:dev:watcher": "pnpm run dev",