create-better-t-stack 2.1.3 → 2.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -19
- package/dist/index.js +97 -97
- package/package.json +1 -1
- package/templates/api/orpc/web/svelte/src/lib/orpc.ts.hbs +31 -0
- package/templates/auth/web/svelte/src/components/SignInForm.svelte +108 -0
- package/templates/auth/web/svelte/src/components/SignUpForm.svelte +142 -0
- package/templates/auth/web/svelte/src/components/UserMenu.svelte +54 -0
- package/templates/auth/web/svelte/src/lib/auth-client.ts +6 -0
- package/templates/auth/web/svelte/src/routes/dashboard/+page.svelte +31 -0
- package/templates/auth/web/svelte/src/routes/login/+page.svelte +12 -0
- package/templates/examples/ai/web/nuxt/app/pages/ai.vue +1 -2
- package/templates/examples/ai/web/svelte/src/routes/ai/+page.svelte +98 -0
- package/templates/examples/todo/web/svelte/src/routes/todos/+page.svelte +150 -0
- package/templates/frontend/native/_gitignore +3 -2
- package/templates/frontend/react/tanstack-router/vite.config.ts.hbs +0 -2
- package/templates/frontend/svelte/_gitignore +23 -0
- package/templates/frontend/svelte/_npmrc +1 -0
- package/templates/frontend/svelte/package.json +31 -0
- package/templates/frontend/svelte/src/app.css +5 -0
- package/templates/frontend/svelte/src/app.d.ts +13 -0
- package/templates/frontend/svelte/src/app.html +12 -0
- package/templates/frontend/svelte/src/components/Header.svelte.hbs +40 -0
- package/templates/frontend/svelte/src/lib/index.ts +1 -0
- package/templates/frontend/svelte/src/routes/+layout.svelte.hbs +19 -0
- package/templates/frontend/svelte/src/routes/+page.svelte.hbs +44 -0
- package/templates/frontend/svelte/static/favicon.png +0 -0
- package/templates/frontend/svelte/svelte.config.js +18 -0
- package/templates/frontend/svelte/tsconfig.json +19 -0
- package/templates/frontend/svelte/vite.config.ts +7 -0
package/package.json
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { PUBLIC_SERVER_URL } from "$env/static/public";
|
|
2
|
+
import { createORPCClient } from "@orpc/client";
|
|
3
|
+
import { RPCLink } from "@orpc/client/fetch";
|
|
4
|
+
import type { RouterClient } from "@orpc/server";
|
|
5
|
+
import { createORPCSvelteQueryUtils } from "@orpc/svelte-query";
|
|
6
|
+
import { QueryCache, QueryClient } from "@tanstack/svelte-query";
|
|
7
|
+
import type { appRouter } from "../../../server/src/routers/index";
|
|
8
|
+
|
|
9
|
+
export const queryClient = new QueryClient({
|
|
10
|
+
queryCache: new QueryCache({
|
|
11
|
+
onError: (error) => {
|
|
12
|
+
console.error(`Error: ${error.message}`);
|
|
13
|
+
},
|
|
14
|
+
}),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export const link = new RPCLink({
|
|
18
|
+
url: `${PUBLIC_SERVER_URL}/rpc`,
|
|
19
|
+
{{#if auth}}
|
|
20
|
+
fetch(url, options) {
|
|
21
|
+
return fetch(url, {
|
|
22
|
+
...options,
|
|
23
|
+
credentials: "include",
|
|
24
|
+
});
|
|
25
|
+
},
|
|
26
|
+
{{/if}}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const client: RouterClient<typeof appRouter> = createORPCClient(link);
|
|
30
|
+
|
|
31
|
+
export const orpc = createORPCSvelteQueryUtils(client);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { createForm } from '@tanstack/svelte-form';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { authClient } from '$lib/auth-client';
|
|
5
|
+
import { goto } from '$app/navigation';
|
|
6
|
+
|
|
7
|
+
let { switchToSignUp } = $props<{ switchToSignUp: () => void }>();
|
|
8
|
+
|
|
9
|
+
const validationSchema = z.object({
|
|
10
|
+
email: z.string().email('Invalid email address'),
|
|
11
|
+
password: z.string().min(1, 'Password is required'),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const form = createForm(() => ({
|
|
15
|
+
defaultValues: { email: '', password: '' },
|
|
16
|
+
onSubmit: async ({ value }) => {
|
|
17
|
+
await authClient.signIn.email(
|
|
18
|
+
{ email: value.email, password: value.password },
|
|
19
|
+
{
|
|
20
|
+
onSuccess: () => goto('/dashboard'),
|
|
21
|
+
onError: (error) => {
|
|
22
|
+
console.log(error.error.message || 'Sign in failed. Please try again.');
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
},
|
|
28
|
+
validators: {
|
|
29
|
+
onSubmit: validationSchema,
|
|
30
|
+
},
|
|
31
|
+
}));
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<div class="mx-auto mt-10 w-full max-w-md p-6">
|
|
35
|
+
<h1 class="mb-6 text-center font-bold text-3xl">Welcome Back</h1>
|
|
36
|
+
|
|
37
|
+
<form
|
|
38
|
+
class="space-y-4"
|
|
39
|
+
onsubmit={(e) => {
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
e.stopPropagation();
|
|
42
|
+
form.handleSubmit();
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
<form.Field name="email">
|
|
46
|
+
{#snippet children(field)}
|
|
47
|
+
<div class="space-y-1">
|
|
48
|
+
<label for={field.name}>Email</label>
|
|
49
|
+
<input
|
|
50
|
+
id={field.name}
|
|
51
|
+
name={field.name}
|
|
52
|
+
type="email"
|
|
53
|
+
class="w-full border"
|
|
54
|
+
onblur={field.handleBlur}
|
|
55
|
+
value={field.state.value}
|
|
56
|
+
oninput={(e: Event) => {
|
|
57
|
+
const target = e.target as HTMLInputElement
|
|
58
|
+
field.handleChange(target.value)
|
|
59
|
+
}} />
|
|
60
|
+
{#if field.state.meta.isTouched}
|
|
61
|
+
{#each field.state.meta.errors as error}
|
|
62
|
+
<p class="text-sm text-red-500" role="alert">{error}</p>
|
|
63
|
+
{/each}
|
|
64
|
+
{/if}
|
|
65
|
+
</div>
|
|
66
|
+
{/snippet}
|
|
67
|
+
</form.Field>
|
|
68
|
+
|
|
69
|
+
<form.Field name="password">
|
|
70
|
+
{#snippet children(field)}
|
|
71
|
+
<div class="space-y-1">
|
|
72
|
+
<label for={field.name}>Password</label>
|
|
73
|
+
<input
|
|
74
|
+
id={field.name}
|
|
75
|
+
name={field.name}
|
|
76
|
+
type="password"
|
|
77
|
+
class="w-full border"
|
|
78
|
+
onblur={field.handleBlur}
|
|
79
|
+
value={field.state.value}
|
|
80
|
+
oninput={(e: Event) => {
|
|
81
|
+
const target = e.target as HTMLInputElement
|
|
82
|
+
field.handleChange(target.value)
|
|
83
|
+
}}
|
|
84
|
+
/>
|
|
85
|
+
{#if field.state.meta.isTouched}
|
|
86
|
+
{#each field.state.meta.errors as error}
|
|
87
|
+
<p class="text-sm text-red-500" role="alert">{error}</p>
|
|
88
|
+
{/each}
|
|
89
|
+
{/if}
|
|
90
|
+
</div>
|
|
91
|
+
{/snippet}
|
|
92
|
+
</form.Field>
|
|
93
|
+
|
|
94
|
+
<form.Subscribe selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}>
|
|
95
|
+
{#snippet children(state)}
|
|
96
|
+
<button type="submit" class="w-full" disabled={!state.canSubmit || state.isSubmitting}>
|
|
97
|
+
{state.isSubmitting ? 'Submitting...' : 'Sign In'}
|
|
98
|
+
</button>
|
|
99
|
+
{/snippet}
|
|
100
|
+
</form.Subscribe>
|
|
101
|
+
</form>
|
|
102
|
+
|
|
103
|
+
<div class="mt-4 text-center">
|
|
104
|
+
<button type="button" class="text-indigo-600 hover:text-indigo-800" onclick={switchToSignUp}>
|
|
105
|
+
Need an account? Sign Up
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { createForm } from '@tanstack/svelte-form';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { authClient } from '$lib/auth-client';
|
|
5
|
+
import { goto } from '$app/navigation';
|
|
6
|
+
|
|
7
|
+
let { switchToSignIn } = $props<{ switchToSignIn: () => void }>();
|
|
8
|
+
|
|
9
|
+
const validationSchema = z.object({
|
|
10
|
+
name: z.string().min(2, 'Name must be at least 2 characters'),
|
|
11
|
+
email: z.string().email('Invalid email address'),
|
|
12
|
+
password: z.string().min(6, 'Password must be at least 6 characters'),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
const form = createForm(() => ({
|
|
17
|
+
defaultValues: { name: '', email: '', password: '' },
|
|
18
|
+
onSubmit: async ({ value }) => {
|
|
19
|
+
await authClient.signUp.email(
|
|
20
|
+
{
|
|
21
|
+
email: value.email,
|
|
22
|
+
password: value.password,
|
|
23
|
+
name: value.name,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
onSuccess: () => {
|
|
27
|
+
goto('/dashboard');
|
|
28
|
+
},
|
|
29
|
+
onError: (error) => {
|
|
30
|
+
console.log(error.error.message || 'Sign up failed. Please try again.');
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
},
|
|
36
|
+
validators: {
|
|
37
|
+
onSubmit: validationSchema,
|
|
38
|
+
},
|
|
39
|
+
}));
|
|
40
|
+
</script>
|
|
41
|
+
|
|
42
|
+
<div class="mx-auto mt-10 w-full max-w-md p-6">
|
|
43
|
+
<h1 class="mb-6 text-center font-bold text-3xl">Create Account</h1>
|
|
44
|
+
|
|
45
|
+
<form
|
|
46
|
+
id="form"
|
|
47
|
+
class="space-y-4"
|
|
48
|
+
onsubmit={(e) => {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
e.stopPropagation();
|
|
51
|
+
form.handleSubmit();
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
<form.Field name="name">
|
|
55
|
+
{#snippet children(field)}
|
|
56
|
+
<div class="space-y-1">
|
|
57
|
+
<label for={field.name}>Name</label>
|
|
58
|
+
<input
|
|
59
|
+
id={field.name}
|
|
60
|
+
name={field.name}
|
|
61
|
+
class="w-full border"
|
|
62
|
+
onblur={field.handleBlur}
|
|
63
|
+
value={field.state.value}
|
|
64
|
+
oninput={(e: Event) => {
|
|
65
|
+
const target = e.target as HTMLInputElement
|
|
66
|
+
field.handleChange(target.value)
|
|
67
|
+
}}
|
|
68
|
+
/>
|
|
69
|
+
{#if field.state.meta.isTouched}
|
|
70
|
+
{#each field.state.meta.errors as error}
|
|
71
|
+
<p class="text-sm text-red-500" role="alert">{error}</p>
|
|
72
|
+
{/each}
|
|
73
|
+
{/if}
|
|
74
|
+
</div>
|
|
75
|
+
{/snippet}
|
|
76
|
+
</form.Field>
|
|
77
|
+
|
|
78
|
+
<form.Field name="email">
|
|
79
|
+
{#snippet children(field)}
|
|
80
|
+
<div class="space-y-1">
|
|
81
|
+
<label for={field.name}>Email</label>
|
|
82
|
+
<input
|
|
83
|
+
id={field.name}
|
|
84
|
+
name={field.name}
|
|
85
|
+
type="email"
|
|
86
|
+
class="w-full border"
|
|
87
|
+
onblur={field.handleBlur}
|
|
88
|
+
value={field.state.value}
|
|
89
|
+
oninput={(e: Event) => {
|
|
90
|
+
const target = e.target as HTMLInputElement
|
|
91
|
+
field.handleChange(target.value)
|
|
92
|
+
}}
|
|
93
|
+
/>
|
|
94
|
+
{#if field.state.meta.isTouched}
|
|
95
|
+
{#each field.state.meta.errors as error}
|
|
96
|
+
<p class="text-sm text-red-500" role="alert">{error}</p>
|
|
97
|
+
{/each}
|
|
98
|
+
{/if}
|
|
99
|
+
</div>
|
|
100
|
+
{/snippet}
|
|
101
|
+
</form.Field>
|
|
102
|
+
|
|
103
|
+
<form.Field name="password">
|
|
104
|
+
{#snippet children(field)}
|
|
105
|
+
<div class="space-y-1">
|
|
106
|
+
<label for={field.name}>Password</label>
|
|
107
|
+
<input
|
|
108
|
+
id={field.name}
|
|
109
|
+
name={field.name}
|
|
110
|
+
type="password"
|
|
111
|
+
class="w-full border"
|
|
112
|
+
onblur={field.handleBlur}
|
|
113
|
+
value={field.state.value}
|
|
114
|
+
oninput={(e: Event) => {
|
|
115
|
+
const target = e.target as HTMLInputElement
|
|
116
|
+
field.handleChange(target.value)
|
|
117
|
+
}}
|
|
118
|
+
/>
|
|
119
|
+
{#if field.state.meta.errors}
|
|
120
|
+
{#each field.state.meta.errors as error}
|
|
121
|
+
<p class="text-sm text-red-500" role="alert">{error}</p>
|
|
122
|
+
{/each}
|
|
123
|
+
{/if}
|
|
124
|
+
</div>
|
|
125
|
+
{/snippet}
|
|
126
|
+
</form.Field>
|
|
127
|
+
|
|
128
|
+
<form.Subscribe selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}>
|
|
129
|
+
{#snippet children(state)}
|
|
130
|
+
<button type="submit" class="w-full" disabled={!state.canSubmit || state.isSubmitting}>
|
|
131
|
+
{state.isSubmitting ? 'Submitting...' : 'Sign Up'}
|
|
132
|
+
</button>
|
|
133
|
+
{/snippet}
|
|
134
|
+
</form.Subscribe>
|
|
135
|
+
</form>
|
|
136
|
+
|
|
137
|
+
<div class="mt-4 text-center">
|
|
138
|
+
<button type="button" class="text-indigo-600 hover:text-indigo-800" onclick={switchToSignIn}>
|
|
139
|
+
Already have an account? Sign In
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { authClient } from '$lib/auth-client';
|
|
3
|
+
import { goto } from '$app/navigation';
|
|
4
|
+
import { queryClient } from '$lib/orpc';
|
|
5
|
+
|
|
6
|
+
const sessionQuery = authClient.useSession();
|
|
7
|
+
|
|
8
|
+
async function handleSignOut() {
|
|
9
|
+
await authClient.signOut({
|
|
10
|
+
fetchOptions: {
|
|
11
|
+
onSuccess: () => {
|
|
12
|
+
queryClient.invalidateQueries();
|
|
13
|
+
goto('/');
|
|
14
|
+
},
|
|
15
|
+
onError: (error) => {
|
|
16
|
+
console.error('Sign out failed:', error);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function goToLogin() {
|
|
23
|
+
goto('/login');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<div class="relative">
|
|
29
|
+
{#if $sessionQuery.isPending}
|
|
30
|
+
<div class="h-8 w-24 animate-pulse rounded bg-neutral-700"></div>
|
|
31
|
+
{:else if $sessionQuery.data?.user}
|
|
32
|
+
{@const user = $sessionQuery.data.user}
|
|
33
|
+
<div class="flex items-center gap-3">
|
|
34
|
+
<span class="text-sm text-neutral-300 hidden sm:inline" title={user.email}>
|
|
35
|
+
{user.name || user.email?.split('@')[0] || 'User'}
|
|
36
|
+
</span>
|
|
37
|
+
<button
|
|
38
|
+
onclick={handleSignOut}
|
|
39
|
+
class="rounded px-3 py-1 text-sm bg-red-600 hover:bg-red-700 text-white transition-colors"
|
|
40
|
+
>
|
|
41
|
+
Sign Out
|
|
42
|
+
</button>
|
|
43
|
+
</div>
|
|
44
|
+
{:else}
|
|
45
|
+
<div class="flex items-center gap-2">
|
|
46
|
+
<button
|
|
47
|
+
onclick={goToLogin}
|
|
48
|
+
class="rounded px-3 py-1 text-sm bg-indigo-600 hover:bg-indigo-700 text-white transition-colors"
|
|
49
|
+
>
|
|
50
|
+
Sign In
|
|
51
|
+
</button>
|
|
52
|
+
</div>
|
|
53
|
+
{/if}
|
|
54
|
+
</div>
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import { goto } from '$app/navigation';
|
|
4
|
+
import { authClient } from '$lib/auth-client';
|
|
5
|
+
import { orpc } from '$lib/orpc';
|
|
6
|
+
import { createQuery } from '@tanstack/svelte-query';
|
|
7
|
+
import { get } from 'svelte/store';
|
|
8
|
+
|
|
9
|
+
const sessionQuery = authClient.useSession();
|
|
10
|
+
|
|
11
|
+
const privateDataQuery = createQuery(orpc.privateData.queryOptions());
|
|
12
|
+
|
|
13
|
+
onMount(() => {
|
|
14
|
+
const { data: session, isPending } = get(sessionQuery);
|
|
15
|
+
if (!session && !isPending) {
|
|
16
|
+
goto('/login');
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
{#if $sessionQuery.isPending}
|
|
22
|
+
<div>Loading...</div>
|
|
23
|
+
{:else if !$sessionQuery.data}
|
|
24
|
+
<!-- Redirecting... -->
|
|
25
|
+
{:else}
|
|
26
|
+
<div>
|
|
27
|
+
<h1>Dashboard</h1>
|
|
28
|
+
<p>Welcome {$sessionQuery.data.user.name}</p>
|
|
29
|
+
<p>privateData: {$privateDataQuery.data?.message}</p>
|
|
30
|
+
</div>
|
|
31
|
+
{/if}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import SignInForm from '../../components/SignInForm.svelte';
|
|
3
|
+
import SignUpForm from '../../components/SignUpForm.svelte';
|
|
4
|
+
|
|
5
|
+
let showSignIn = $state(true);
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
{#if showSignIn}
|
|
9
|
+
<SignInForm switchToSignUp={() => showSignIn = false} />
|
|
10
|
+
{:else}
|
|
11
|
+
<SignUpForm switchToSignIn={() => showSignIn = true} />
|
|
12
|
+
{/if}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { ref, watch, nextTick } from 'vue'
|
|
3
2
|
import { useChat } from '@ai-sdk/vue'
|
|
3
|
+
import { nextTick, ref, watch } from 'vue'
|
|
4
4
|
|
|
5
5
|
const config = useRuntimeConfig()
|
|
6
6
|
const serverUrl = config.public.serverURL
|
|
@@ -16,7 +16,6 @@ watch(messages, async () => {
|
|
|
16
16
|
messagesEndRef.value?.scrollIntoView({ behavior: 'smooth' })
|
|
17
17
|
})
|
|
18
18
|
|
|
19
|
-
// Helper: Concatenate all text parts for a message
|
|
20
19
|
function getMessageText(message: any) {
|
|
21
20
|
return message.parts
|
|
22
21
|
.filter((part: any) => part.type === 'text')
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { PUBLIC_SERVER_URL } from '$env/static/public';
|
|
3
|
+
import { Chat } from '@ai-sdk/svelte';
|
|
4
|
+
|
|
5
|
+
const chat = new Chat({
|
|
6
|
+
api: `${PUBLIC_SERVER_URL}/ai`,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
let messagesEndElement: HTMLDivElement | null = null;
|
|
10
|
+
|
|
11
|
+
$effect(() => {
|
|
12
|
+
const messageCount = chat.messages.length;
|
|
13
|
+
if (messageCount > 0) {
|
|
14
|
+
setTimeout(() => {
|
|
15
|
+
messagesEndElement?.scrollIntoView({ behavior: 'smooth' });
|
|
16
|
+
}, 0);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<div class="mx-auto grid h-full w-full max-w-2xl grid-rows-[1fr_auto] overflow-hidden p-4">
|
|
23
|
+
<div class="mb-4 space-y-4 overflow-y-auto pb-4">
|
|
24
|
+
{#if chat.messages.length === 0}
|
|
25
|
+
<div class="mt-8 text-center text-neutral-500">Ask the AI anything to get started!</div>
|
|
26
|
+
{/if}
|
|
27
|
+
|
|
28
|
+
{#each chat.messages as message (message.id)}
|
|
29
|
+
<div
|
|
30
|
+
class="w-fit max-w-[85%] rounded-lg p-3 text-sm md:text-base"
|
|
31
|
+
class:ml-auto={message.role === 'user'}
|
|
32
|
+
class:bg-indigo-600={message.role === 'user'}
|
|
33
|
+
class:text-white={message.role === 'user'}
|
|
34
|
+
class:bg-neutral-700={message.role === 'assistant'}
|
|
35
|
+
class:text-neutral-100={message.role === 'assistant'}
|
|
36
|
+
>
|
|
37
|
+
<p
|
|
38
|
+
class="mb-1 text-xs font-semibold uppercase tracking-wide"
|
|
39
|
+
class:text-indigo-200={message.role === 'user'}
|
|
40
|
+
class:text-neutral-400={message.role === 'assistant'}
|
|
41
|
+
>
|
|
42
|
+
{message.role === 'user' ? 'You' : 'AI Assistant'}
|
|
43
|
+
</p>
|
|
44
|
+
<div class="whitespace-pre-wrap break-words">
|
|
45
|
+
{#each message.parts as part, partIndex (partIndex)}
|
|
46
|
+
{#if part.type === 'text'}
|
|
47
|
+
{part.text}
|
|
48
|
+
{:else if part.type === 'tool-invocation'}
|
|
49
|
+
<pre class="mt-2 rounded bg-neutral-800 p-2 text-xs text-neutral-300"
|
|
50
|
+
>{JSON.stringify(part.toolInvocation, null, 2)}</pre
|
|
51
|
+
>
|
|
52
|
+
{/if}
|
|
53
|
+
{/each}
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
{/each}
|
|
57
|
+
<div bind:this={messagesEndElement}></div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<form
|
|
61
|
+
onsubmit={chat.handleSubmit}
|
|
62
|
+
class="flex w-full items-center space-x-2 border-t border-neutral-700 pt-4"
|
|
63
|
+
>
|
|
64
|
+
<input
|
|
65
|
+
name="prompt"
|
|
66
|
+
bind:value={chat.input}
|
|
67
|
+
placeholder="Type your message..."
|
|
68
|
+
class="flex-1 rounded border border-neutral-600 bg-neutral-800 px-3 py-2 text-neutral-100 placeholder-neutral-500 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 disabled:opacity-50"
|
|
69
|
+
autocomplete="off"
|
|
70
|
+
onkeydown={(e) => {
|
|
71
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
chat.handleSubmit(e);
|
|
74
|
+
}
|
|
75
|
+
}}
|
|
76
|
+
/>
|
|
77
|
+
<button
|
|
78
|
+
type="submit"
|
|
79
|
+
disabled={!chat.input.trim()}
|
|
80
|
+
class="inline-flex h-10 w-10 items-center justify-center rounded bg-indigo-600 text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-neutral-900 disabled:cursor-not-allowed disabled:opacity-50"
|
|
81
|
+
aria-label="Send message"
|
|
82
|
+
>
|
|
83
|
+
<svg
|
|
84
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
85
|
+
width="18"
|
|
86
|
+
height="18"
|
|
87
|
+
viewBox="0 0 24 24"
|
|
88
|
+
fill="none"
|
|
89
|
+
stroke="currentColor"
|
|
90
|
+
stroke-width="2"
|
|
91
|
+
stroke-linecap="round"
|
|
92
|
+
stroke-linejoin="round"
|
|
93
|
+
>
|
|
94
|
+
<path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" />
|
|
95
|
+
</svg>
|
|
96
|
+
</button>
|
|
97
|
+
</form>
|
|
98
|
+
</div>
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { orpc } from '$lib/orpc';
|
|
3
|
+
import { createQuery, createMutation } from '@tanstack/svelte-query';
|
|
4
|
+
|
|
5
|
+
let newTodoText = $state('');
|
|
6
|
+
|
|
7
|
+
const todosQuery = createQuery(orpc.todo.getAll.queryOptions());
|
|
8
|
+
|
|
9
|
+
const addMutation = createMutation(
|
|
10
|
+
orpc.todo.create.mutationOptions({
|
|
11
|
+
onSuccess: () => {
|
|
12
|
+
$todosQuery.refetch();
|
|
13
|
+
newTodoText = '';
|
|
14
|
+
},
|
|
15
|
+
onError: (error) => {
|
|
16
|
+
console.error('Failed to create todo:', error?.message ?? error);
|
|
17
|
+
},
|
|
18
|
+
})
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const toggleMutation = createMutation(
|
|
22
|
+
orpc.todo.toggle.mutationOptions({
|
|
23
|
+
onSuccess: () => {
|
|
24
|
+
$todosQuery.refetch();
|
|
25
|
+
},
|
|
26
|
+
onError: (error) => {
|
|
27
|
+
console.error('Failed to toggle todo:', error?.message ?? error);
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const deleteMutation = createMutation(
|
|
33
|
+
orpc.todo.delete.mutationOptions({
|
|
34
|
+
onSuccess: () => {
|
|
35
|
+
$todosQuery.refetch();
|
|
36
|
+
},
|
|
37
|
+
onError: (error) => {
|
|
38
|
+
console.error('Failed to delete todo:', error?.message ?? error);
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
function handleAddTodo(event: SubmitEvent) {
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
const text = newTodoText.trim();
|
|
46
|
+
if (text) {
|
|
47
|
+
$addMutation.mutate({ text });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function handleToggleTodo(id: number, completed: boolean) {
|
|
52
|
+
$toggleMutation.mutate({ id, completed: !completed });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function handleDeleteTodo(id: number) {
|
|
56
|
+
$deleteMutation.mutate({ id });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const isAdding = $derived($addMutation.isPending);
|
|
60
|
+
const canAdd = $derived(!isAdding && newTodoText.trim().length > 0);
|
|
61
|
+
const isLoadingTodos = $derived($todosQuery.isLoading);
|
|
62
|
+
const todos = $derived($todosQuery.data ?? []);
|
|
63
|
+
const hasTodos = $derived(todos.length > 0);
|
|
64
|
+
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<div class="p-4">
|
|
68
|
+
<h1 class="text-xl mb-4">Todos</h1>
|
|
69
|
+
|
|
70
|
+
<form onsubmit={handleAddTodo} class="flex gap-2 mb-4">
|
|
71
|
+
<input
|
|
72
|
+
type="text"
|
|
73
|
+
bind:value={newTodoText}
|
|
74
|
+
placeholder="New task..."
|
|
75
|
+
disabled={isAdding}
|
|
76
|
+
class=" p-1 flex-grow"
|
|
77
|
+
/>
|
|
78
|
+
<button
|
|
79
|
+
type="submit"
|
|
80
|
+
disabled={!canAdd}
|
|
81
|
+
class="bg-blue-500 text-white px-3 py-1 rounded disabled:opacity-50"
|
|
82
|
+
>
|
|
83
|
+
{#if isAdding}Adding...{:else}Add{/if}
|
|
84
|
+
</button>
|
|
85
|
+
</form>
|
|
86
|
+
|
|
87
|
+
{#if isLoadingTodos}
|
|
88
|
+
<p>Loading...</p>
|
|
89
|
+
{:else if !hasTodos}
|
|
90
|
+
<p>No todos yet.</p>
|
|
91
|
+
{:else}
|
|
92
|
+
<ul class="space-y-1">
|
|
93
|
+
{#each todos as todo (todo.id)}
|
|
94
|
+
{@const isToggling = $toggleMutation.isPending && $toggleMutation.variables?.id === todo.id}
|
|
95
|
+
{@const isDeleting = $deleteMutation.isPending && $deleteMutation.variables?.id === todo.id}
|
|
96
|
+
{@const isDisabled = isToggling || isDeleting}
|
|
97
|
+
<li
|
|
98
|
+
class="flex items-center justify-between p-2 "
|
|
99
|
+
class:opacity-50={isDisabled}
|
|
100
|
+
>
|
|
101
|
+
<div class="flex items-center gap-2">
|
|
102
|
+
<input
|
|
103
|
+
type="checkbox"
|
|
104
|
+
id={`todo-${todo.id}`}
|
|
105
|
+
checked={todo.completed}
|
|
106
|
+
onchange={() => handleToggleTodo(todo.id, todo.completed)}
|
|
107
|
+
disabled={isDisabled}
|
|
108
|
+
/>
|
|
109
|
+
<label
|
|
110
|
+
for={`todo-${todo.id}`}
|
|
111
|
+
class:line-through={todo.completed}
|
|
112
|
+
>
|
|
113
|
+
{todo.text}
|
|
114
|
+
</label>
|
|
115
|
+
</div>
|
|
116
|
+
<button
|
|
117
|
+
type="button"
|
|
118
|
+
onclick={() => handleDeleteTodo(todo.id)}
|
|
119
|
+
disabled={isDisabled}
|
|
120
|
+
aria-label="Delete todo"
|
|
121
|
+
class="text-red-500 px-1 disabled:opacity-50"
|
|
122
|
+
>
|
|
123
|
+
{#if isDeleting}Deleting...{:else}X{/if}
|
|
124
|
+
</button>
|
|
125
|
+
</li>
|
|
126
|
+
{/each}
|
|
127
|
+
</ul>
|
|
128
|
+
{/if}
|
|
129
|
+
|
|
130
|
+
{#if $todosQuery.isError}
|
|
131
|
+
<p class="mt-4 text-red-500">
|
|
132
|
+
Error loading: {$todosQuery.error?.message ?? 'Unknown error'}
|
|
133
|
+
</p>
|
|
134
|
+
{/if}
|
|
135
|
+
{#if $addMutation.isError}
|
|
136
|
+
<p class="mt-4 text-red-500">
|
|
137
|
+
Error adding: {$addMutation.error?.message ?? 'Unknown error'}
|
|
138
|
+
</p>
|
|
139
|
+
{/if}
|
|
140
|
+
{#if $toggleMutation.isError}
|
|
141
|
+
<p class="mt-4 text-red-500">
|
|
142
|
+
Error updating: {$toggleMutation.error?.message ?? 'Unknown error'}
|
|
143
|
+
</p>
|
|
144
|
+
{/if}
|
|
145
|
+
{#if $deleteMutation.isError}
|
|
146
|
+
<p class="mt-4 text-red-500">
|
|
147
|
+
Error deleting: {$deleteMutation.error?.message ?? 'Unknown error'}
|
|
148
|
+
</p>
|
|
149
|
+
{/if}
|
|
150
|
+
</div>
|
|
@@ -12,7 +12,8 @@ web-build/
|
|
|
12
12
|
# expo router
|
|
13
13
|
expo-env.d.ts
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
.env
|
|
16
|
+
.cache
|
|
16
17
|
|
|
17
18
|
ios
|
|
18
19
|
android
|
|
@@ -21,4 +22,4 @@ android
|
|
|
21
22
|
.DS_Store
|
|
22
23
|
|
|
23
24
|
# Temporary files created by Metro to check the health of the file watcher
|
|
24
|
-
.metro-health-check*
|
|
25
|
+
.metro-health-check*
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
{{! Import VitePWA only if 'pwa' addon is selected }}
|
|
2
1
|
{{#if (includes addons "pwa")}}
|
|
3
2
|
import { VitePWA } from "vite-plugin-pwa";
|
|
4
3
|
{{/if}}
|
|
@@ -13,7 +12,6 @@ export default defineConfig({
|
|
|
13
12
|
tailwindcss(),
|
|
14
13
|
TanStackRouterVite({}),
|
|
15
14
|
react(),
|
|
16
|
-
{{! Add VitePWA plugin config only if 'pwa' addon is selected }}
|
|
17
15
|
{{#if (includes addons "pwa")}}
|
|
18
16
|
VitePWA({
|
|
19
17
|
registerType: "autoUpdate",
|