Personal finance tracker
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

wip: wallets route integration

+262 -61
+4
.gitignore
··· 57 57 .env.testing 58 58 tmp 59 59 *.db 60 + 61 + # Auto-generated type declarations 62 + auto-imports.d.ts 63 + components.d.ts
+4 -4
backend/internal/utils/utils.go
··· 3 3 import "time" 4 4 5 5 type BaseModelSchema struct { 6 - ID uint64 `bun:"id,pk,autoincrement" json:"id"` 7 - CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"createdAt"` 8 - UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updatedAt"` 9 - DeletedAt time.Time `bun:"deleted_at,soft_delete,nullzero" json:"deletedAt"` 6 + ID uint64 `bun:"id,pk,autoincrement"` 7 + CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"` 8 + UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp"` 9 + DeletedAt time.Time `bun:"deleted_at,soft_delete,nullzero"` 10 10 }
+4 -4
backend/oauth/user.go
··· 11 11 type UserModel struct { 12 12 bun.BaseModel `bun:"table:users"` 13 13 14 - AccountDID syntax.DID `bun:"account_did,pk" json:"accountDID"` 15 - Handle string `bun:"handle,unique,notnull" json:"handle"` 16 - CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp" json:"createdAt"` 17 - UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp" json:"updatedAt"` 14 + AccountDID syntax.DID `bun:"account_did,pk"` 15 + Handle string `bun:"handle,unique,notnull"` 16 + CreatedAt time.Time `bun:"created_at,nullzero,notnull,default:current_timestamp"` 17 + UpdatedAt time.Time `bun:"updated_at,nullzero,notnull,default:current_timestamp"` 18 18 } 19 19 20 20 func (oa *OAuth) upsertUser(ctx context.Context, accountDID syntax.DID, handle string) error {
+7 -5
backend/wallet/wallet_model.go
··· 11 11 bun.BaseModel `bun:"table:wallets"` 12 12 utils.BaseModelSchema 13 13 14 - Name string `bun:"name" json:"name"` 14 + Name string `bun:"name"` 15 + Balance uint64 `bun:"balance"` 15 16 } 16 17 17 18 type CreateWalletSchema struct { 18 - Name string `json:"name"` 19 + Name string `json:"name"` 20 + Balance uint64 `json:"balance"` 19 21 } 20 22 21 23 type updateWalletSchema struct { 22 24 *CreateWalletSchema 23 - ID uint64 `json:"id"` 25 + ID uint64 24 26 } 25 27 26 28 func getWallets(ctx context.Context, db *bun.DB) (*[]WalletModel, error) { ··· 42 44 } 43 45 44 46 func createWallet(ctx context.Context, db *bun.DB, schema *CreateWalletSchema) (*WalletModel, error) { 45 - wallet := &WalletModel{Name: schema.Name} 47 + wallet := &WalletModel{Name: schema.Name, Balance: schema.Balance} 46 48 _, err := db.NewInsert().Model(wallet).Exec(ctx) 47 49 if err != nil { 48 50 return nil, err ··· 51 53 } 52 54 53 55 func updateWallet(ctx context.Context, db *bun.DB, schema *updateWalletSchema) (*WalletModel, error) { 54 - wallet := &WalletModel{Name: schema.Name} 56 + wallet := &WalletModel{Name: schema.Name, Balance: schema.Balance} 55 57 _, err := db.NewUpdate().Model(wallet).Where("id = ?", schema.ID).Exec(ctx) 56 58 if err != nil { 57 59 return nil, err
+1 -1
frontend/index.html
··· 7 7 <title>Vite App</title> 8 8 </head> 9 9 <body> 10 - <div id="app"></div> 10 + <div id="app" class="isolate"></div> 11 11 <script type="module" src="/src/main.ts"></script> 12 12 </body> 13 13 </html>
+3
frontend/package.json
··· 17 17 }, 18 18 "dependencies": { 19 19 "@jeffydc/ruta-vue": "jsr:0.0.1772081898", 20 + "@nuxt/ui": "4.5.0", 20 21 "@playwright/test": "1.58.2", 21 22 "@tanstack/vue-query": "5.92.9", 22 23 "@tsconfig/node24": "24.0.4", ··· 27 28 "openapi-typescript": "7.13.0", 28 29 "oxfmt": "0.35.0", 29 30 "oxlint": "1.50.0", 31 + "tailwindcss": "4.2.1", 30 32 "typescript": "5.9.3", 31 33 "vite": "7.3.1", 32 34 "vite-plugin-vue-devtools": "8.0.6", 33 35 "vitest": "4.0.18", 34 36 "vue": "3.5.29", 37 + "vue-router": "5.0.3", 35 38 "vue-tsc": "3.2.5" 36 39 }, 37 40 "engines": {
+3 -1
frontend/src/App.vue
··· 3 3 </script> 4 4 5 5 <template> 6 - <MatchedRoutes /> 6 + <UApp> 7 + <MatchedRoutes /> 8 + </UApp> 7 9 </template>
+16 -10
frontend/src/api/schema.d.ts
··· 64 64 * @example https://example.com/schemas/CreateWalletSchema.json 65 65 */ 66 66 readonly $schema?: string; 67 + /** Format: int64 */ 68 + balance: number; 67 69 name: string; 68 70 }; 69 71 ErrorDetail: { ··· 121 123 */ 122 124 readonly $schema?: string; 123 125 /** Format: int64 */ 124 - id: number; 126 + ID: number; 127 + /** Format: int64 */ 128 + balance: number; 125 129 name: string; 126 130 }; 127 131 UserModel: { ··· 131 135 * @example https://example.com/schemas/UserModel.json 132 136 */ 133 137 readonly $schema?: string; 134 - accountDID: string; 138 + AccountDID: string; 135 139 /** Format: date-time */ 136 - createdAt: string; 137 - handle: string; 140 + CreatedAt: string; 141 + Handle: string; 138 142 /** Format: date-time */ 139 - updatedAt: string; 143 + UpdatedAt: string; 140 144 }; 141 145 WalletModel: { 142 146 /** ··· 145 149 * @example https://example.com/schemas/WalletModel.json 146 150 */ 147 151 readonly $schema?: string; 152 + /** Format: int64 */ 153 + Balance: number; 148 154 /** Format: date-time */ 149 - createdAt: string; 155 + CreatedAt: string; 150 156 /** Format: date-time */ 151 - deletedAt: string; 157 + DeletedAt: string; 152 158 /** Format: int64 */ 153 - id: number; 154 - name: string; 159 + ID: number; 160 + Name: string; 155 161 /** Format: date-time */ 156 - updatedAt: string; 162 + UpdatedAt: string; 157 163 }; 158 164 }; 159 165 responses: never;
+2 -2
frontend/src/api/user.ts
··· 5 5 6 6 export function meQueryOpts() { 7 7 return queryOptions({ 8 - queryKey: ['api', 'me'], 8 + queryKey: ['me'], 9 9 queryFn: async () => { 10 10 const { data, error } = await openAPIClient.GET('/me'); 11 - if (error) throw error.detail; 11 + if (error) throw error; 12 12 return data; 13 13 }, 14 14 });
+35
frontend/src/api/wallet.ts
··· 1 + import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'; 2 + import { proxyRefs } from 'vue'; 3 + 4 + import { openAPIClient } from './client'; 5 + 6 + export function walletsQueryOpts() { 7 + return queryOptions({ 8 + queryKey: ['wallets'], 9 + queryFn: async () => { 10 + const { data, error } = await openAPIClient.GET('/wallets'); 11 + if (error) throw error; 12 + return data; 13 + }, 14 + }); 15 + } 16 + 17 + export function useWallets() { 18 + return proxyRefs(useQuery(walletsQueryOpts())); 19 + } 20 + 21 + export function useNewWallet() { 22 + const qc = useQueryClient(); 23 + return proxyRefs( 24 + useMutation({ 25 + mutationFn: async (body: Parameters<typeof openAPIClient.POST>['1']['body']) => { 26 + const { data, error } = await openAPIClient.POST('/wallets', { body }); 27 + if (error) throw error; 28 + return data; 29 + }, 30 + onSuccess: async () => { 31 + await qc.invalidateQueries({ queryKey: walletsQueryOpts().queryKey }); 32 + }, 33 + }), 34 + ); 35 + }
+2
frontend/src/main.css
··· 1 + @import 'tailwindcss'; 2 + @import '@nuxt/ui';
+25 -4
frontend/src/main.ts
··· 1 - import { RutaVue } from '@jeffydc/ruta-vue'; 1 + import './main.css'; 2 + import { redirect, RutaVue } from '@jeffydc/ruta-vue'; 3 + import nuxtUI from '@nuxt/ui/vue-plugin'; 2 4 import { VueQueryPlugin } from '@tanstack/vue-query'; 3 5 import { createApp } from 'vue'; 6 + 7 + import type { components } from './api/schema'; 4 8 5 9 import { routes } from './+routes.gen'; 6 10 import { meQueryOpts } from './api/user'; ··· 9 13 const app = createApp(App); 10 14 const router = new RutaVue({ routes }); 11 15 12 - router.before(async ({ context }) => { 16 + router.before(async ({ context, to }) => { 13 17 try { 14 18 await context.qc.ensureQueryData(meQueryOpts()); 15 - } catch (error) {} 19 + // Redirect back to `/` when user is logged in and navigate to `/login` 20 + if (to.path === '/login') { 21 + redirect('/'); 22 + } 23 + } catch (e) { 24 + // User is not logged in, do not redirect or throw on `/login` route 25 + if (to.path === '/login') { 26 + return; 27 + } 28 + const err = e as components['schemas']['ErrorModel']; 29 + if (err.status === 401) { 30 + redirect('/login'); 31 + } 32 + throw err.title; 33 + } 16 34 }); 17 35 18 36 export type Router = typeof router; 19 37 20 - app.use(router).use(VueQueryPlugin, { queryClient: router.context.qc }); 38 + app 39 + .use(router) 40 + .use(nuxtUI) 41 + .use(VueQueryPlugin, { queryClient: router.context.qc, enableDevtoolsV6Plugin: true }); 21 42 router.navigate().then(() => app.mount('#app'));
+9 -9
frontend/src/routes/+root-config.ts
··· 1 1 import { createRouteBuilder } from '@jeffydc/ruta-vue'; 2 2 import { QueryClient } from '@tanstack/vue-query'; 3 3 4 - import { meQueryOpts } from '../api/user'; 4 + const TEN_MINUTES = 10 * 60 * 1000; 5 5 6 6 export const route = createRouteBuilder(null, '/', () => ({ 7 - qc: new QueryClient(), 8 - })) 9 - .layout({ 10 - load: async ({ context }) => { 11 - try { 12 - await context.qc.ensureQueryData(meQueryOpts()); 13 - } catch (error) {} 7 + qc: new QueryClient({ 8 + defaultOptions: { 9 + queries: { 10 + staleTime: TEN_MINUTES, 11 + }, 14 12 }, 15 - }) 13 + }), 14 + })) 15 + .layout() 16 16 .page();
+2 -2
frontend/src/routes/+root-error.vue
··· 10 10 </script> 11 11 12 12 <template> 13 - {{ err }} 14 - <slot></slot> 13 + <UError v-if="err" :error="{ message: err }" /> 14 + <slot v-else></slot> 15 15 </template>
+3 -1
frontend/src/routes/+root-layout.vue
··· 1 1 <script setup lang="ts"></script> 2 2 3 3 <template> 4 - <slot></slot> 4 + <UMain> 5 + <slot></slot> 6 + </UMain> 5 7 </template>
+2 -5
frontend/src/routes/+root-page.vue
··· 5 5 </script> 6 6 7 7 <template> 8 - <form action="/oauth/login" method="post"> 9 - <label for="handle">Handle</label> 10 - <input type="text" name="handle" id="handle" /> 11 - <button type="submit">Login</button> 12 - </form> 8 + <pre>{{ me.data }}</pre> 9 + <a href="/wallets">Wallets</a> 13 10 </template>
+5
frontend/src/routes/login/+login-config.ts
··· 1 + import { createRouteBuilder } from '@jeffydc/ruta-vue'; 2 + 3 + import { parentRoute } from './+route.gen'; 4 + 5 + export const route = createRouteBuilder(parentRoute, 'login').page();
+15
frontend/src/routes/login/+login-page.vue
··· 1 + <template> 2 + <div class="w-screen h-screen flex place-items-center place-content-center"> 3 + <form action="/oauth/login" method="post" class="flex flex-col gap-4 w-75 sm:w-100"> 4 + <h1 class="text-3xl">Subete すべて</h1> 5 + <label for="handle">Your Internet Handle</label> 6 + <UInput type="text" name="handle" id="handle" placeholder="anonymous.bsky.social" size="xl" /> 7 + <UAlert variant="outline" color="neutral"> 8 + <template #description> 9 + Use your <a href="https://atproto.com" target="_blank">AT Protocol</a> handle to log in. 10 + </template> 11 + </UAlert> 12 + <UButton size="xl" type="submit">Login</UButton> 13 + </form> 14 + </div> 15 + </template>
+8 -1
frontend/src/routes/wallets/+wallets-config.ts
··· 1 1 import { createRouteBuilder } from '@jeffydc/ruta-vue'; 2 2 3 + import { walletsQueryOpts } from '../../api/wallet.ts'; 3 4 import { parentRoute } from './+route.gen.ts'; 4 5 5 - export const route = createRouteBuilder(parentRoute, 'wallets').layout().page(); 6 + export const route = createRouteBuilder(parentRoute, 'wallets') 7 + .layout() 8 + .page({ 9 + load: async ({ context }) => { 10 + await context.qc.ensureQueryData(walletsQueryOpts()); 11 + }, 12 + });
+14 -2
frontend/src/routes/wallets/+wallets-error.vue
··· 1 - <script setup lang="ts"></script> 1 + <script setup lang="ts"> 2 + import { onErrorCaptured, ref } from 'vue'; 2 3 3 - <template></template> 4 + const err = ref(); 5 + 6 + onErrorCaptured((e) => { 7 + err.value = e; 8 + return false; 9 + }); 10 + </script> 11 + 12 + <template> 13 + <UError v-if="err" :error="{ message: err }" /> 14 + <slot v-else></slot> 15 + </template>
-3
frontend/src/routes/wallets/+wallets-layout.vue
··· 1 - <script setup lang="ts"></script> 2 - 3 - <template></template>
+66 -2
frontend/src/routes/wallets/+wallets-page.vue
··· 1 - <script setup lang="ts"></script> 1 + <script setup lang="ts"> 2 + import { ref, shallowReactive } from 'vue'; 3 + import { useNewWallet, useWallets } from '../../api/wallet'; 4 + import { RouteTyped } from './+route.gen'; 5 + 6 + const walletForm = shallowReactive({ 7 + name: '', 8 + balance: 0, 9 + }); 2 10 3 - <template></template> 11 + const router = RouteTyped.useRouter(); 12 + const wallets = useWallets(); 13 + const newWallet = useNewWallet(); 14 + 15 + const isCreating = ref(false); 16 + 17 + function handleNewWallet() { 18 + newWallet.mutate(walletForm, { 19 + onSuccess: () => { 20 + isCreating.value = false; 21 + walletForm.balance = 0; 22 + walletForm.name = ''; 23 + }, 24 + }); 25 + } 26 + </script> 27 + 28 + <template> 29 + <div class="flex justify-end items-center"> 30 + <UButton @click="isCreating = true">Create a New Wallet</UButton> 31 + </div> 32 + 33 + <UForm 34 + v-show="isCreating" 35 + class="w-75 sm:w-100 flex flex-col gap-4" 36 + :state="walletForm" 37 + @submit="handleNewWallet" 38 + > 39 + <UFormField label="Wallet Name" name="name"> 40 + <UInput 41 + type="text" 42 + placeholder="USD Wallet or TWD Wallet" 43 + class="w-full" 44 + v-model="walletForm.name" 45 + /> 46 + </UFormField> 47 + 48 + <UFormField label="Current Balance" name="balance"> 49 + <UInput type="number" class="w-full" v-model="walletForm.balance" /> 50 + </UFormField> 51 + 52 + <UButton :loading="newWallet.isPending" type="submit">Create</UButton> 53 + 54 + <UAlert v-if="newWallet.isError"> 55 + {{ newWallet.error }} 56 + </UAlert> 57 + </UForm> 58 + 59 + <template v-for="wallet in wallets.data" :key="wallet.ID"> 60 + <a :href="router.href({ path: '/wallets/:walletId', params: { walletId: wallet.ID } })"> 61 + <UCard> 62 + <template #header>{{ wallet.Name }}</template> 63 + <template #default>${{ wallet.Balance }}</template> 64 + </UCard> 65 + </a> 66 + </template> 67 + </template>
+7 -1
frontend/src/routes/wallets/wallet/+wallet-config.ts
··· 2 2 3 3 import { parentRoute } from './+route.gen.ts'; 4 4 5 - export const route = createRouteBuilder(parentRoute, ':walletId').layout().page(); 5 + export const route = createRouteBuilder(parentRoute, ':walletId') 6 + .layout({ 7 + parseParams(params) { 8 + return { walletId: +params.walletId }; 9 + }, 10 + }) 11 + .page();
+5 -2
frontend/tsconfig.app.json
··· 1 1 { 2 2 "extends": ["@vue/tsconfig/tsconfig.dom.json", "./.ruta/tsconfig.json"], 3 - "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], 3 + "include": ["env.d.ts", "src/**/*", "src/**/*.vue", "auto-imports.d.ts", "components.d.ts"], 4 4 "exclude": ["src/**/__tests__/*"], 5 5 "compilerOptions": { 6 - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo" 6 + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 7 + "paths": { 8 + "#build/ui/*": ["./node_modules/.nuxt-ui/ui/*"] 9 + } 7 10 } 8 11 }
+4 -1
frontend/tsconfig.node.json
··· 14 14 15 15 "module": "ESNext", 16 16 "moduleResolution": "Bundler", 17 - "types": ["node"] 17 + "types": ["node"], 18 + "paths": { 19 + "#build/ui": ["./node_modules/.nuxt-ui/ui"] 20 + } 18 21 } 19 22 }
+16 -1
frontend/vite.config.ts
··· 1 1 import fsp from 'node:fs/promises'; 2 2 3 3 import { ruta } from '@jeffydc/ruta-vue/vite'; 4 + import nuxtUI from '@nuxt/ui/vite'; 4 5 import vue from '@vitejs/plugin-vue'; 5 6 import openAPITS, { astToString } from 'openapi-typescript'; 6 7 import { defineConfig, Plugin } from 'vite'; ··· 11 12 12 13 // https://vite.dev/config/ 13 14 export default defineConfig({ 14 - plugins: [vue(), vueDevTools(), ruta({ routerModule: './src/main.ts' }), openAPI()], 15 + plugins: [ 16 + vue(), 17 + vueDevTools(), 18 + ruta({ routerModule: './src/main.ts' }), 19 + nuxtUI({ 20 + router: false, 21 + components: { dirs: [] }, 22 + ui: { 23 + colors: { 24 + primary: 'lime', 25 + }, 26 + }, 27 + }), 28 + openAPI(), 29 + ], 15 30 server: { 16 31 host: SERVER_HOST, 17 32 proxy: {