Adding paraglide for i18n support and dropdown to header. Formatting code based on a new SvelteKit Svelte5 app.

This commit is contained in:
Bradley Shellnut 2024-11-14 16:48:21 -08:00
parent b5dae43ba4
commit 3204b0b28b
72 changed files with 2904 additions and 952 deletions

View file

@ -1,13 +0,0 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View file

@ -1,31 +0,0 @@
/** @type { import("eslint").Linter.Config } */
module.exports = {
root: true,
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2017: true,
node: true
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
]
};

View file

@ -1,13 +0,0 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View file

@ -1,11 +0,0 @@
{
"useTabs": true,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"pluginSearchDirs": ["."],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

View file

@ -1,3 +0,0 @@
boredgame.localhost {
reverse_proxy / localhost:4173
}

View file

@ -1,55 +1,65 @@
{ {
"$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"formatter": { "vcs": {
"enabled": true, "enabled": false,
"formatWithErrors": false, "clientKind": "git",
"indentStyle": "tab", "useIgnoreFile": false
"indentWidth": 2, },
"lineEnding": "lf", "files": { "ignoreUnknown": false, "ignore": [] },
"lineWidth": 150, "formatter": {
"attributePosition": "auto", "enabled": true,
"ignore": [ "formatWithErrors": false,
"**/.DS_Store", "indentStyle": "space",
"**/node_modules", "indentWidth": 2,
"./build", "lineEnding": "lf",
"./.svelte-kit", "lineWidth": 150,
"./package", "attributePosition": "auto",
"**/.env", "ignore": [
"**/.env.*", "**/.DS_Store",
"**/pnpm-lock.yaml", "**/node_modules",
"**/package-lock.json", "./build",
"**/yarn.lock" "./.svelte-kit",
] "./package",
}, "**/.env",
"organizeImports": { "enabled": true }, "**/.env.*",
"linter": { "enabled": true, "rules": { "recommended": true } }, "**/pnpm-lock.yaml",
"javascript": { "**/package-lock.json",
"formatter": { "**/yarn.lock",
"jsxQuoteStyle": "double", "**/paraglide/**"
"quoteProperties": "asNeeded", ]
"trailingCommas": "all", },
"semicolons": "always", "organizeImports": { "enabled": true },
"arrowParentheses": "always", "linter": { "enabled": true, "rules": { "recommended": true } },
"bracketSpacing": true, "javascript": {
"bracketSameLine": false, "formatter": {
"quoteStyle": "single", "jsxQuoteStyle": "double",
"attributePosition": "auto" "quoteProperties": "asNeeded",
}, "trailingCommas": "all",
"parser": { "indentStyle": "space",
"unsafeParameterDecoratorsEnabled": true "lineEnding": "lf",
} "lineWidth": 150,
}, "semicolons": "always",
"overrides": [ "arrowParentheses": "always",
{ "bracketSpacing": true,
"include": ["*.svelte"], "bracketSameLine": false,
"linter": { "quoteStyle": "single",
"rules": { "attributePosition": "auto"
"style": { },
"useConst": "off", "parser": {
"useImportType": "off" "unsafeParameterDecoratorsEnabled": true
} }
} },
} "overrides": [
} {
] "include": ["*.svelte"],
"linter": {
"rules": {
"style": {
"useConst": "off",
"useImportType": "off"
}
}
}
}
]
} }

4
messages/en.json Normal file
View file

@ -0,0 +1,4 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"hello_world": "Hello, {name} from en!"
}

4
messages/es.json Normal file
View file

@ -0,0 +1,4 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"hello_world": "Hello, {name} from es!"
}

View file

@ -12,7 +12,7 @@
"build": "vite build", "build": "vite build",
"package": "svelte-kit package", "package": "svelte-kit package",
"preview": "vite preview", "preview": "vite preview",
"test": "playwright test", "test:e2e": "playwright test",
"test:ui": "svelte-kit sync && playwright test --ui", "test:ui": "svelte-kit sync && playwright test --ui",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@ -23,14 +23,14 @@
"test:unit": "vitest" "test:unit": "vitest"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.8.3", "@biomejs/biome": "^1.9.4",
"@faker-js/faker": "^8.4.1", "@faker-js/faker": "^8.4.1",
"@melt-ui/pp": "^0.3.2", "@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.83.0", "@melt-ui/svelte": "^0.83.0",
"@playwright/test": "^1.48.2", "@playwright/test": "^1.48.2",
"@sveltejs/adapter-auto": "^3.3.1", "@sveltejs/adapter-auto": "^3.3.1",
"@sveltejs/enhanced-img": "^0.3.10", "@sveltejs/enhanced-img": "^0.3.10",
"@sveltejs/kit": "^2.8.0", "@sveltejs/kit": "^2.8.1",
"@sveltejs/vite-plugin-svelte": "4.0.0-next.7", "@sveltejs/vite-plugin-svelte": "4.0.0-next.7",
"@types/cookie": "^0.6.0", "@types/cookie": "^0.6.0",
"@types/node": "^20.17.6", "@types/node": "^20.17.6",
@ -42,9 +42,6 @@
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"bits-ui": "^0.21.16", "bits-ui": "^0.21.16",
"drizzle-kit": "^0.27.2", "drizzle-kit": "^0.27.2",
"eslint": "^8.57.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "2.36.0-next.13",
"formsnap": "^1.0.1", "formsnap": "^1.0.1",
"just-clone": "^6.2.0", "just-clone": "^6.2.0",
"just-debounce-it": "^3.2.0", "just-debounce-it": "^3.2.0",
@ -52,12 +49,12 @@
"lucide-svelte": "^0.408.0", "lucide-svelte": "^0.408.0",
"mode-watcher": "^0.4.1", "mode-watcher": "^0.4.1",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",
"postcss": "^8.4.47", "postcss": "^8.4.49",
"postcss-import": "^16.1.0", "postcss-import": "^16.1.0",
"postcss-load-config": "^5.1.0", "postcss-load-config": "^5.1.0",
"postcss-preset-env": "^9.6.0", "postcss-preset-env": "^9.6.0",
"prettier": "^3.3.3", "prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.7", "prettier-plugin-svelte": "^3.2.8",
"svelte": "5.0.0-next.175", "svelte": "5.0.0-next.175",
"svelte-check": "^3.8.6", "svelte-check": "^3.8.6",
"svelte-headless-table": "^0.18.3", "svelte-headless-table": "^0.18.3",
@ -72,7 +69,7 @@
"tslib": "^2.8.1", "tslib": "^2.8.1",
"tsx": "^4.19.2", "tsx": "^4.19.2",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"vite": "^5.4.10", "vite": "^5.4.11",
"vitest": "^1.6.0", "vitest": "^1.6.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
@ -84,6 +81,7 @@
"@hono/zod-validator": "^0.2.2", "@hono/zod-validator": "^0.2.2",
"@iconify-icons/line-md": "^1.2.30", "@iconify-icons/line-md": "^1.2.30",
"@iconify-icons/mdi": "^1.2.48", "@iconify-icons/mdi": "^1.2.48",
"@inlang/paraglide-sveltekit": "^0.11.1",
"@internationalized/date": "^3.5.6", "@internationalized/date": "^3.5.6",
"@lucia-auth/adapter-drizzle": "^1.1.0", "@lucia-auth/adapter-drizzle": "^1.1.0",
"@lukeed/uuid": "^2.0.1", "@lukeed/uuid": "^2.0.1",
@ -102,17 +100,17 @@
"@sveltejs/adapter-vercel": "^5.4.7", "@sveltejs/adapter-vercel": "^5.4.7",
"@types/feather-icons": "^4.29.4", "@types/feather-icons": "^4.29.4",
"boardgamegeekclient": "^1.9.1", "boardgamegeekclient": "^1.9.1",
"bullmq": "^5.25.4", "bullmq": "^5.25.6",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cookie": "^1.0.1", "cookie": "^1.0.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"dotenv-expand": "^11.0.6", "dotenv-expand": "^11.0.7",
"drizzle-orm": "^0.36.1", "drizzle-orm": "^0.36.1",
"drizzle-zod": "^0.5.1", "drizzle-zod": "^0.5.1",
"feather-icons": "^4.29.2", "feather-icons": "^4.29.2",
"handlebars": "^4.7.8", "handlebars": "^4.7.8",
"hono": "^4.6.9", "hono": "^4.6.10",
"hono-pino": "^0.3.0", "hono-pino": "^0.3.0",
"hono-rate-limiter": "^0.4.0", "hono-rate-limiter": "^0.4.0",
"hono-zod-openapi": "^0.4.2", "hono-zod-openapi": "^0.4.2",

View file

@ -1,12 +1,10 @@
import type { PlaywrightTestConfig } from '@playwright/test'; import { defineConfig } from '@playwright/test';
const config: PlaywrightTestConfig = { export default defineConfig({
webServer: { webServer: {
command: 'npm run build && npm run preview', command: 'npm run build && npm run preview',
port: 4173 port: 4173
}, },
testDir: 'tests', testDir: 'e2e',
testMatch: /(.+\.)?(test|spec)\.[jt]s/ testMatch: /(.+\.)?(test|spec)\.[jt]s/,
}; });
export default config;

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,5 @@
const tailwindcss = require("tailwindcss"); const tailwindcss = require("tailwindcss");
const tailwindNesting = require('tailwindcss/nesting'); const tailwindNesting = require('tailwindcss/nesting');
const autoprefixer = require('autoprefixer');
const postcssPresetEnv = require('postcss-preset-env'); const postcssPresetEnv = require('postcss-preset-env');
const atImport = require('postcss-import'); const atImport = require('postcss-import');

1
project.inlang/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
cache

View file

@ -0,0 +1 @@
927922ef9fe834ca9a0dea407aa01519bbcb60dadb84c6bd9cafd324f6108c44

View file

@ -0,0 +1,20 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-empty-pattern@1/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-identical-pattern@1/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-missing-translation@1/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-without-source@1/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-valid-js-identifier@1/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@2/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@0/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{languageTag}.json"
},
"sourceLanguageTag": "en",
"languageTags": [
"en",
"es"
]
}

67
src/app.d.ts vendored
View file

@ -1,44 +1,41 @@
import type { ApiClient } from '$lib/server/api'; import type { ApiClient } from '$lib/server/api';
import type { Users } from '$lib/server/api/databases/postgres/tables';
import type { parseApiResponse } from '$lib/utils/api'; import type { parseApiResponse } from '$lib/utils/api';
import type { User } from 'lucia';
// See https://kit.svelte.dev/docs/types#app // See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces // for information about these interfaces
// and what to do when importing types
// src/app.d.ts
declare global { declare global {
namespace App { namespace App {
interface PageData { interface PageData {
flash?: { flash?: {
type: 'success' | 'error' | 'info'; type: 'success' | 'error' | 'info';
message: string; message: string;
data?: Record<string, unknown>; data?: Record<string, unknown>;
}; };
} }
interface Locals { interface Locals {
api: ApiClient['api']; api: ApiClient['api'];
parseApiResponse: typeof parseApiResponse; parseApiResponse: typeof parseApiResponse;
getAuthedUser: () => Promise<Returned<User> | null>; getAuthedUser: () => Promise<Returned<Users> | null>;
getAuthedUserOrThrow: () => Promise<Returned<User>>; getAuthedUserOrThrow: () => Promise<Returned<User>>;
} }
namespace Superforms { namespace Superforms {
type Message = { type Message = {
type: 'error' | 'success' | 'info'; type: 'error' | 'success' | 'info';
text: string; text: string;
}; };
} }
interface Error { interface Error {
code?: string; code?: string;
errorId?: string; errorId?: string;
} }
} }
interface Document { interface Document {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: <explanation> // biome-ignore lint/suspicious/noExplicitAny: <explanation>
startViewTransition: (callback: never) => void; // Add your custom property/method here startViewTransition: (callback: never) => void; // Add your custom property/method here
} }
} }
// THIS IS IMPORTANT!!! // THIS IS IMPORTANT!!!

View file

@ -1,16 +1,15 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="%paraglide.lang%" dir="%paraglide.textDirection%">
<head> <head>
<meta name="robots" content="noindex, nofollow"/> <meta name="robots" content="noindex, nofollow"/>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="description" content="Bored? Find a game! Bored Game!"/> <meta name="description" content="Bored? Find a game! Bored Game!"/>
<link rel="icon" href="%sveltekit.assets%/favicon-bored-game.svg"/> <link rel="icon" href="%sveltekit.assets%/favicon-bored-game.svg"/>
<meta name="viewport" content="width=device-width"/> <meta name="viewport" content="width=device-width, initial-scale=1"/>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body> <body data-sveltekit-preload-data="hover">
<div id="svelte">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
</body> </body>
</html> </html>

View file

@ -1,18 +1,21 @@
import 'reflect-metadata'; import "reflect-metadata";
import { StatusCodes } from '$lib/constants/status-codes'; import { StatusCodes } from "$lib/constants/status-codes";
import type { ApiRoutes } from '$lib/server/api'; import type { ApiRoutes } from "$lib/server/api";
import { parseApiResponse } from '$lib/utils/api'; import { parseApiResponse } from "$lib/utils/api";
import { type Handle, redirect } from '@sveltejs/kit'; import { type Handle, redirect } from "@sveltejs/kit";
import { sequence } from '@sveltejs/kit/hooks'; import { sequence } from "@sveltejs/kit/hooks";
import { hc } from 'hono/client'; import { hc } from "hono/client";
import { i18n } from "$lib/i18n";
const handleParaglide: Handle = i18n.handle();
const apiClient: Handle = async ({ event, resolve }) => { const apiClient: Handle = async ({ event, resolve }) => {
/* ------------------------------ Register api ------------------------------ */ /* ------------------------------ Register api ------------------------------ */
const { api } = hc<ApiRoutes>('/', { const { api } = hc<ApiRoutes>("/", {
fetch: event.fetch, fetch: event.fetch,
headers: { headers: {
'x-forwarded-for': event.url.host.includes('sveltekit-prerender') ? '127.0.0.1' : event.getClientAddress(), "x-forwarded-for": event.url.host.includes("sveltekit-prerender") ? "127.0.0.1" : event.getClientAddress(),
host: event.request.headers.get('host') || '', host: event.request.headers.get("host") || "",
}, },
}); });
@ -25,7 +28,7 @@ const apiClient: Handle = async ({ event, resolve }) => {
async function getAuthedUserOrThrow() { async function getAuthedUserOrThrow() {
const { data } = await api.user.$get().then(parseApiResponse); const { data } = await api.user.$get().then(parseApiResponse);
if (!data || !data.user) { if (!data || !data.user) {
throw redirect(StatusCodes.TEMPORARY_REDIRECT, '/'); throw redirect(StatusCodes.TEMPORARY_REDIRECT, "/");
} }
return data?.user; return data?.user;
} }
@ -40,4 +43,4 @@ const apiClient: Handle = async ({ event, resolve }) => {
return await resolve(event); return await resolve(event);
}; };
export const handle: Handle = sequence(apiClient); export const handle: Handle = sequence(apiClient, handleParaglide);

2
src/hooks.ts Normal file
View file

@ -0,0 +1,2 @@
import { i18n } from '$lib/i18n';
export const reroute = i18n.reroute();

View file

@ -1,20 +1,20 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { toastMessage } from '$lib/utils/superforms'; // Adjust the path if necessary import { toastMessage } from '$lib/utils/superforms'; // Adjust the path if necessary
const { codeContent, language }: { codeContent: string; language: string } = $props() const { codeContent, language }: { codeContent: string; language: string } = $props();
// Function to copy code to clipboard // Function to copy code to clipboard
const copyToClipboard = () => { const copyToClipboard = () => {
navigator.clipboard navigator.clipboard
.writeText(codeContent) .writeText(codeContent)
.then(() => { .then(() => {
toastMessage({ text: 'Copied to clipboard!', type: 'success' }) toastMessage({ text: 'Copied to clipboard!', type: 'success' });
}) })
.catch((err) => { .catch((err) => {
console.error('Failed to copy: ', err) console.error('Failed to copy: ', err);
}) });
} };
</script> </script>
{#if codeContent} {#if codeContent}
@ -24,7 +24,7 @@ const copyToClipboard = () => {
{codeContent} {codeContent}
</span> </span>
</code> </code>
<Button class="copy-button" on:click={copyToClipboard}>Copy</Button> <Button class="copy-button" onclick={copyToClipboard}>Copy</Button>
</div> </div>
{/if} {/if}

View file

@ -3,10 +3,32 @@ import Logo from '$components/logo.svelte';
import * as Avatar from '$lib/components/ui/avatar'; import * as Avatar from '$lib/components/ui/avatar';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { ListChecks, ListTodo, LogOut, Settings } from 'lucide-svelte'; import { ListChecks, ListTodo, LogOut, Settings } from 'lucide-svelte';
import { type AvailableLanguageTag, languageTag } from '$lib/paraglide/runtime';
import { i18n } from '$lib/i18n';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import type { Users } from '$lib/server/api/databases/postgres/tables';
let { user = null } = $props(); let { user = null }: { user: Users | null } = $props();
let avatar: string = $derived(user?.username?.slice(0, 1).toUpperCase() || ':)'); let avatar: string = $derived(user?.username?.slice(0, 1).toUpperCase() || ':)');
let language = $derived.by(() => {
switch (languageTag()) {
case 'en':
return '🇺🇸';
case 'es':
return '🇲🇽';
default:
return '🇺🇸';
}
});
function switchToLanguage(newLanguage: AvailableLanguageTag) {
const canonicalPath = i18n.route($page.url.pathname);
const localisedPath = i18n.resolveRoute(canonicalPath, newLanguage);
goto(localisedPath);
}
</script> </script>
<header> <header>
@ -25,9 +47,23 @@ let avatar: string = $derived(user?.username?.slice(0, 1).toUpperCase() || ':)')
<a href="/login"> <span class="flex-auto">Login</span></a> <a href="/login"> <span class="flex-auto">Login</span></a>
<a href="/signup"> <span class="flex-auto">Sign Up</span></a> <a href="/signup"> <span class="flex-auto">Sign Up</span></a>
{/if} {/if}
{@render languageDropdown()}
</nav> </nav>
</header> </header>
{#snippet languageDropdown()}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<span class="flex-auto">{language}</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<button onclick={() => switchToLanguage('en')}><DropdownMenu.Item><span>🇺🇸 English</span></DropdownMenu.Item></button>
<DropdownMenu.Separator />
<button onclick={() => switchToLanguage('es')}><DropdownMenu.Item><span>🇲🇽 Spanish</span></DropdownMenu.Item></button>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/snippet}
{#snippet userDropdown()} {#snippet userDropdown()}
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>

View file

@ -1,18 +1,18 @@
<!-- Taken from carbon design system svelte --> <!-- Taken from carbon design system svelte -->
<!-- https://github.com/carbon-design-system/carbon-components-svelte/blob/master/src/SkeletonPlaceholder/SkeletonPlaceholder.svelte --> <!-- https://github.com/carbon-design-system/carbon-components-svelte/blob/master/src/SkeletonPlaceholder/SkeletonPlaceholder.svelte -->
<script lang="ts"> <script lang="ts">
export let style: string let style: string = $props();
</script> </script>
<!-- svelte-ignore a11y-mouse-events-have-key-events --> <!-- svelte-ignore a11y-mouse-events-have-key-events -->
<div <div
{style} {style}
class:bx--skeleton__placeholder={true} class:bx--skeleton__placeholder={true}
{...$$restProps} <!-- {...$$restProps}-->
on:click <!-- click-->
on:mouseover <!-- mouseover-->
on:mouseenter <!-- mouseenter-->
on:mouseleave <!-- mouseleave-->
/> />
<style lang="postcss"> <style lang="postcss">

View file

@ -0,0 +1,19 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import { cn } from "$lib/utils/ui.js";
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: DropdownMenuPrimitive.GroupHeadingProps & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.GroupHeading
bind:ref
class={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...restProps}
/>

View file

@ -0,0 +1,15 @@
import Root from "./input-otp.svelte";
import Group from "./input-otp-group.svelte";
import Slot from "./input-otp-slot.svelte";
import Separator from "./input-otp-separator.svelte";
export {
Root,
Group,
Slot,
Separator,
Root as InputOTP,
Group as InputOTPGroup,
Slot as InputOTPSlot,
Separator as InputOTPSeparator,
};

View file

@ -0,0 +1,16 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils/ui.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} class={cn("flex items-center", className)} {...restProps}>
{@render children?.()}
</div>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import Dot from "lucide-svelte/icons/dot";
let {
ref = $bindable(null),
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div bind:this={ref} role="separator" {...restProps}>
{#if children}
{@render children?.()}
{:else}
<Dot />
{/if}
</div>

View file

@ -0,0 +1,30 @@
<script lang="ts">
import { PinInput as InputOTPPrimitive } from "bits-ui";
import type { ComponentProps } from "svelte";
import { cn } from "$lib/utils/ui.js";
let {
ref = $bindable(null),
cell,
class: className,
...restProps
}: ComponentProps<typeof InputOTPPrimitive.Cell> = $props();
</script>
<InputOTPPrimitive.Cell
{cell}
bind:ref
class={cn(
"border-input relative flex h-10 w-10 items-center justify-center border-y border-r text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
cell.isActive && "ring-ring ring-offset-background z-10 ring-2",
className
)}
{...restProps}
>
{cell.char}
{#if cell.hasFakeCaret}
<div class="pointer-events-none absolute inset-0 flex items-center justify-center">
<div class="animate-caret-blink bg-foreground h-4 w-px duration-1000"></div>
</div>
{/if}
</InputOTPPrimitive.Cell>

View file

@ -0,0 +1,22 @@
<script lang="ts">
import { PinInput as InputOTPPrimitive } from "bits-ui";
import type { ComponentProps } from "svelte";
import { cn } from "$lib/utils/ui.js";
let {
ref = $bindable(null),
class: className,
value = $bindable(""),
...restProps
}: ComponentProps<typeof InputOTPPrimitive.Root> = $props();
</script>
<InputOTPPrimitive.Root
bind:ref
bind:value
class={cn(
"flex items-center gap-2 has-[:disabled]:opacity-50 [&_input]:disabled:cursor-not-allowed",
className
)}
{...restProps}
/>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { Select as SelectPrimitive } from "bits-ui";
import { cn } from "$lib/utils/ui.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: SelectPrimitive.GroupHeadingProps = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
class={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...restProps}
/>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import ChevronDown from "lucide-svelte/icons/chevron-down";
import { Select as SelectPrimitive, type WithoutChildrenOrChild } from "bits-ui";
import { cn } from "$lib/utils/ui.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronDown class="size-4" />
</SelectPrimitive.ScrollDownButton>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import ChevronUp from "lucide-svelte/icons/chevron-up";
import { Select as SelectPrimitive, type WithoutChildrenOrChild } from "bits-ui";
import { cn } from "$lib/utils/ui.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
class={cn("flex cursor-default items-center justify-center py-1", className)}
{...restProps}
>
<ChevronUp class="size-4" />
</SelectPrimitive.ScrollUpButton>

View file

@ -0,0 +1,6 @@
export const SIDEBAR_COOKIE_NAME = "sidebar:state";
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
export const SIDEBAR_WIDTH = "16rem";
export const SIDEBAR_WIDTH_MOBILE = "18rem";
export const SIDEBAR_WIDTH_ICON = "3rem";
export const SIDEBAR_KEYBOARD_SHORTCUT = "b";

View file

@ -0,0 +1,81 @@
import { IsMobile } from "$lib/hooks/is-mobile.svelte.js";
import { getContext, setContext } from "svelte";
import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js";
type Getter<T> = () => T;
export type SidebarStateProps = {
/**
* A getter function that returns the current open state of the sidebar.
* We use a getter function here to support `bind:open` on the `Sidebar.Provider`
* component.
*/
open: Getter<boolean>;
/**
* A function that sets the open state of the sidebar. To support `bind:open`, we need
* a source of truth for changing the open state to ensure it will be synced throughout
* the sub-components and any `bind:` references.
*/
setOpen: (open: boolean) => void;
};
class SidebarState {
readonly props: SidebarStateProps;
open = $derived.by(() => this.props.open());
openMobile = $state(false);
setOpen: SidebarStateProps["setOpen"];
#isMobile: IsMobile;
state = $derived.by(() => (this.open ? "expanded" : "collapsed"));
constructor(props: SidebarStateProps) {
this.setOpen = props.setOpen;
this.#isMobile = new IsMobile();
this.props = props;
}
// Convenience getter for checking if the sidebar is mobile
// without this, we would need to use `sidebar.isMobile.current` everywhere
get isMobile() {
return this.#isMobile.current;
}
// Event handler to apply to the `<svelte:window>`
handleShortcutKeydown = (e: KeyboardEvent) => {
if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
this.toggle();
}
};
setOpenMobile = (value: boolean) => {
this.openMobile = value;
};
toggle = () => {
return this.#isMobile.current
? (this.openMobile = !this.openMobile)
: this.setOpen(!this.open);
};
}
const SYMBOL_KEY = "scn-sidebar";
/**
* Instantiates a new `SidebarState` instance and sets it in the context.
*
* @param props The constructor props for the `SidebarState` class.
* @returns The `SidebarState` instance.
*/
export function setSidebar(props: SidebarStateProps): SidebarState {
return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props));
}
/**
* Retrieves the `SidebarState` instance from the context. This is a class instance,
* so you cannot destructure it.
* @returns The `SidebarState` instance.
*/
export function useSidebar(): SidebarState {
return getContext(Symbol.for(SYMBOL_KEY));
}

View file

@ -0,0 +1,75 @@
import { useSidebar } from "./context.svelte.js";
import Content from "./sidebar-content.svelte";
import Footer from "./sidebar-footer.svelte";
import GroupAction from "./sidebar-group-action.svelte";
import GroupContent from "./sidebar-group-content.svelte";
import GroupLabel from "./sidebar-group-label.svelte";
import Group from "./sidebar-group.svelte";
import Header from "./sidebar-header.svelte";
import Input from "./sidebar-input.svelte";
import Inset from "./sidebar-inset.svelte";
import MenuAction from "./sidebar-menu-action.svelte";
import MenuBadge from "./sidebar-menu-badge.svelte";
import MenuButton from "./sidebar-menu-button.svelte";
import MenuItem from "./sidebar-menu-item.svelte";
import MenuSkeleton from "./sidebar-menu-skeleton.svelte";
import MenuSubButton from "./sidebar-menu-sub-button.svelte";
import MenuSubItem from "./sidebar-menu-sub-item.svelte";
import MenuSub from "./sidebar-menu-sub.svelte";
import Menu from "./sidebar-menu.svelte";
import Provider from "./sidebar-provider.svelte";
import Rail from "./sidebar-rail.svelte";
import Separator from "./sidebar-separator.svelte";
import Trigger from "./sidebar-trigger.svelte";
import Root from "./sidebar.svelte";
export {
Content,
Footer,
Group,
GroupAction,
GroupContent,
GroupLabel,
Header,
Input,
Inset,
Menu,
MenuAction,
MenuBadge,
MenuButton,
MenuItem,
MenuSkeleton,
MenuSub,
MenuSubButton,
MenuSubItem,
Provider,
Rail,
Root,
Separator,
//
Root as Sidebar,
Content as SidebarContent,
Footer as SidebarFooter,
Group as SidebarGroup,
GroupAction as SidebarGroupAction,
GroupContent as SidebarGroupContent,
GroupLabel as SidebarGroupLabel,
Header as SidebarHeader,
Input as SidebarInput,
Inset as SidebarInset,
Menu as SidebarMenu,
MenuAction as SidebarMenuAction,
MenuBadge as SidebarMenuBadge,
MenuButton as SidebarMenuButton,
MenuItem as SidebarMenuItem,
MenuSkeleton as SidebarMenuSkeleton,
MenuSub as SidebarMenuSub,
MenuSubButton as SidebarMenuSubButton,
MenuSubItem as SidebarMenuSubItem,
Provider as SidebarProvider,
Rail as SidebarRail,
Separator as SidebarSeparator,
Trigger as SidebarTrigger,
Trigger,
useSidebar,
};

View file

@ -0,0 +1,24 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils/ui.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-sidebar="content"
class={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils/ui.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-sidebar="footer"
class={cn("flex flex-col gap-2 p-2", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,36 @@
<script lang="ts">
import { cn } from "$lib/utils/ui.js";
import type { WithElementRef } from "bits-ui";
import type { Snippet } from "svelte";
import type { HTMLButtonAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
child,
...restProps
}: WithElementRef<HTMLButtonAttributes> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const propObj = $derived({
class: cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute right-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-none transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden",
className
),
"data-sidebar": "group-action",
...restProps,
});
</script>
{#if child}
{@render child({ props: propObj })}
{:else}
<button bind:this={ref} {...propObj}>
{@render children?.()}
</button>
{/if}

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { cn } from "$lib/utils/ui.js";
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-sidebar="group-content"
class={cn("w-full text-sm", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,34 @@
<script lang="ts">
import { cn } from "$lib/utils/ui.js";
import type { WithElementRef } from "bits-ui";
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
children,
child,
class: className,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const mergedProps = $derived({
class: cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-none transition-[margin,opa] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
),
"data-sidebar": "group-label",
...restProps,
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div bind:this={ref} {...mergedProps}>
{@render children?.()}
</div>
{/if}

View file

@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils/ui.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-sidebar="group"
class={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import type { HTMLAttributes } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils/ui.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-sidebar="header"
class={cn("flex flex-col gap-2 p-2", className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import type { ComponentProps } from "svelte";
import { Input } from "$lib/components/ui/input/index.js";
import { cn } from "$lib/utils/ui.js";
let {
ref = $bindable(null),
value = $bindable(""),
class: className,
...restProps
}: ComponentProps<typeof Input> = $props();
</script>
<Input
bind:ref
bind:value
data-sidebar="input"
class={cn(
"bg-background focus-visible:ring-sidebar-ring h-8 w-full shadow-none focus-visible:ring-2",
className
)}
{...restProps}
/>

View file

@ -0,0 +1,24 @@
<script lang="ts">
import { cn } from "$lib/utils/ui.js";
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<main
bind:this={ref}
class={cn(
"bg-background relative flex min-h-svh flex-1 flex-col",
"peer-data-[variant=inset]:min-h-[calc(100svh-theme(spacing.4))] md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className
)}
{...restProps}
>
{@render children?.()}
</main>

View file

@ -0,0 +1,43 @@
<script lang="ts">
import { cn } from "$lib/utils/ui.js";
import type { WithElementRef } from "bits-ui";
import type { Snippet } from "svelte";
import type { HTMLButtonAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
showOnHover = false,
children,
child,
...restProps
}: WithElementRef<HTMLButtonAttributes> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
showOnHover?: boolean;
} = $props();
const mergedProps = $derived({
class: cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-none transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
),
"data-sidebar": "menu-action",
...restProps,
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button bind:this={ref} {...mergedProps}>
{@render children?.()}
</button>
{/if}

View file

@ -0,0 +1,29 @@
<script lang="ts">
import { cn } from "$lib/utils/ui.js";
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
</script>
<div
bind:this={ref}
data-sidebar="menu-badge"
class={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 select-none items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -0,0 +1,97 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const sidebarMenuButtonVariants = tv({
base: "peer/menu-button ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none transition-[width,height,padding] focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background hover:bg-sidebar-accent hover:text-sidebar-accent-foreground shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type SidebarMenuButtonVariant = VariantProps<
typeof sidebarMenuButtonVariants
>["variant"];
export type SidebarMenuButtonSize = VariantProps<typeof sidebarMenuButtonVariants>["size"];
</script>
<script lang="ts">
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
import { cn } from "$lib/utils/ui.js";
import { mergeProps, type WithElementRef, type WithoutChildrenOrChild } from "bits-ui";
import type { ComponentProps, Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
import { useSidebar } from "./context.svelte.js";
let {
ref = $bindable(null),
class: className,
children,
child,
variant = "default",
size = "default",
isActive = false,
tooltipContent,
tooltipContentProps,
...restProps
}: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & {
isActive?: boolean;
variant?: SidebarMenuButtonVariant;
size?: SidebarMenuButtonSize;
tooltipContent?: Snippet;
tooltipContentProps?: WithoutChildrenOrChild<ComponentProps<typeof Tooltip.Content>>;
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const sidebar = useSidebar();
const buttonProps = $derived({
class: cn(sidebarMenuButtonVariants({ variant, size }), className),
"data-sidebar": "menu-button",
"data-size": size,
"data-active": isActive,
...restProps,
});
</script>
{#snippet Button({ props }: { props?: Record<string, unknown> })}
{@const mergedProps = mergeProps(buttonProps, props)}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button bind:this={ref} {...mergedProps}>
{@render children?.()}
</button>
{/if}
{/snippet}
{#if !tooltipContent}
{@render Button({})}
{:else}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
{@render Button({ props })}
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content
side="right"
align="center"
hidden={sidebar.state !== "collapsed" || sidebar.isMobile}
children={tooltipContent}
{...tooltipContentProps}
/>
</Tooltip.Root>
{/if}

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { cn } from "$lib/utils/ui.js";
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLLIElement>, HTMLLIElement> = $props();
</script>
<li
bind:this={ref}
data-sidebar="menu-item"
class={cn("group/menu-item relative", className)}
{...restProps}
>
{@render children?.()}
</li>

View file

@ -0,0 +1,36 @@
<script lang="ts">
import { Skeleton } from "$lib/components/ui/skeleton/index.js";
import { cn } from "$lib/utils/ui.js";
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
showIcon = false,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLElement>> & {
showIcon?: boolean;
} = $props();
// Random width between 50% and 90%
const width = `${Math.floor(Math.random() * 40) + 50}%`;
</script>
<div
bind:this={ref}
data-sidebar="menu-skeleton"
class={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...restProps}
>
{#if showIcon}
<Skeleton class="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />
{/if}
<Skeleton
class="h-4 max-w-[--skeleton-width] flex-1"
data-sidebar="menu-skeleton-text"
style="--skeleton-width: {width};"
/>
{@render children?.()}
</div>

View file

@ -0,0 +1,43 @@
<script lang="ts">
import { cn } from "$lib/utils/ui.js";
import type { WithElementRef } from "bits-ui";
import type { Snippet } from "svelte";
import type { HTMLAnchorAttributes } from "svelte/elements";
let {
ref = $bindable(null),
children,
child,
class: className,
size = "md",
isActive,
...restProps
}: WithElementRef<HTMLAnchorAttributes> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
size?: "sm" | "md";
isActive?: boolean;
} = $props();
const mergedProps = $derived({
class: cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
),
"data-sidebar": "menu-sub-button",
"data-size": size,
"data-active": isActive,
...restProps,
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<a bind:this={ref} {...mergedProps}>
{@render children?.()}
</a>
{/if}

View file

@ -0,0 +1,14 @@
<script lang="ts">
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLLIElement>> = $props();
</script>
<li bind:this={ref} data-sidebar="menu-sub-item" {...restProps}>
{@render children?.()}
</li>

View file

@ -0,0 +1,25 @@
<script lang="ts">
import { cn } from "$lib/utils/ui.js";
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLUListElement>> = $props();
</script>
<ul
bind:this={ref}
data-sidebar="menu-sub"
class={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...restProps}
>
{@render children?.()}
</ul>

View file

@ -0,0 +1,21 @@
<script lang="ts">
import { cn } from "$lib/utils/ui.js";
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLUListElement>, HTMLUListElement> = $props();
</script>
<ul
bind:this={ref}
data-sidebar="menu"
class={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...restProps}
>
{@render children?.()}
</ul>

View file

@ -0,0 +1,59 @@
<script lang="ts">
import * as Tooltip from "$lib/components/ui/tooltip/index.js";
import { cn } from "$lib/utils/ui.js";
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
import {
SIDEBAR_COOKIE_MAX_AGE,
SIDEBAR_COOKIE_NAME,
SIDEBAR_WIDTH,
SIDEBAR_WIDTH_ICON,
} from "./constants.js";
import { setSidebar } from "./context.svelte.js";
let {
ref = $bindable(null),
open = $bindable(true),
onOpenChange = () => {},
controlledOpen = false,
class: className,
style,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
open?: boolean;
onOpenChange?: (open: boolean) => void;
controlledOpen?: boolean;
} = $props();
const sidebar = setSidebar({
open: () => open,
setOpen: (value: boolean) => {
if (controlledOpen) {
onOpenChange(value);
} else {
open = value;
onOpenChange(value);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
});
</script>
<svelte:window onkeydown={sidebar.handleShortcutKeydown} />
<Tooltip.Provider delayDuration={0}>
<div
style="--sidebar-width: {SIDEBAR_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON}; {style}"
class={cn(
"group/sidebar-wrapper has-[[data-variant=inset]]:bg-sidebar flex min-h-svh w-full",
className
)}
bind:this={ref}
{...restProps}
>
{@render children?.()}
</div>
</Tooltip.Provider>

View file

@ -0,0 +1,36 @@
<script lang="ts">
import { cn } from "$lib/utils/ui.js";
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
import { useSidebar } from "./context.svelte.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLButtonElement>, HTMLButtonElement> = $props();
const sidebar = useSidebar();
</script>
<button
bind:this={ref}
data-sidebar="rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onclick={() => sidebar.toggle()}
title="Toggle Sidebar"
class={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:hover:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...restProps}
>
{@render children?.()}
</button>

View file

@ -0,0 +1,18 @@
<script lang="ts">
import { Separator } from "$lib/components/ui/separator/index.js";
import { cn } from "$lib/utils/ui.js";
import type { ComponentProps } from "svelte";
let {
ref = $bindable(null),
class: className,
...restProps
}: ComponentProps<typeof Separator> = $props();
</script>
<Separator
bind:ref
data-sidebar="separator"
class={cn("bg-sidebar-border mx-2 w-auto", className)}
{...restProps}
/>

View file

@ -0,0 +1,34 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button/index.js";
import { cn } from "$lib/utils/ui.js";
import PanelLeft from "lucide-svelte/icons/panel-left";
import type { ComponentProps } from "svelte";
import { useSidebar } from "./context.svelte.js";
let {
ref = $bindable(null),
class: className,
onclick,
...restProps
}: ComponentProps<typeof Button> & {
onclick?: (e: MouseEvent) => void;
} = $props();
const sidebar = useSidebar();
</script>
<Button
type="button"
onclick={(e) => {
onclick?.(e);
sidebar.toggle();
}}
data-sidebar="trigger"
variant="ghost"
size="icon"
class={cn("h-7 w-7", className)}
{...restProps}
>
<PanelLeft />
<span class="sr-only">Toggle Sidebar</span>
</Button>

View file

@ -0,0 +1,98 @@
<script lang="ts">
import * as Sheet from "$lib/components/ui/sheet/index.js";
import { cn } from "$lib/utils/ui.js";
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
import { SIDEBAR_WIDTH_MOBILE } from "./constants.js";
import { useSidebar } from "./context.svelte.js";
let {
ref = $bindable(null),
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
} = $props();
const sidebar = useSidebar();
</script>
{#if collapsible === "none"}
<div
class={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-[--sidebar-width] flex-col",
className
)}
bind:this={ref}
{...restProps}
>
{@render children?.()}
</div>
{:else if sidebar.isMobile}
<Sheet.Root
controlledOpen
open={sidebar.openMobile}
onOpenChange={sidebar.setOpenMobile}
{...restProps}
>
<Sheet.Content
data-sidebar="sidebar"
data-mobile="true"
class="bg-sidebar text-sidebar-foreground w-[--sidebar-width] p-0 [&>button]:hidden"
style="--sidebar-width: {SIDEBAR_WIDTH_MOBILE};"
{side}
>
<div class="flex h-full w-full flex-col">
{@render children?.()}
</div>
</Sheet.Content>
</Sheet.Root>
{:else}
<div
bind:this={ref}
class="text-sidebar-foreground group peer hidden md:block"
data-state={sidebar.state}
data-collapsible={sidebar.state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
>
<!-- This is what handles the sidebar gap on desktop -->
<div
class={cn(
"relative h-svh w-[--sidebar-width] bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4))]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]"
)}
></div>
<div
class={cn(
"fixed inset-y-0 z-10 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...restProps}
>
<div
data-sidebar="sidebar"
class="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow"
>
{@render children?.()}
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,7 @@
import Root from "./skeleton.svelte";
export {
Root,
//
Root as Skeleton,
};

View file

@ -0,0 +1,17 @@
<script lang="ts">
import type { WithElementRef, WithoutChildren } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils/ui.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
</script>
<div
bind:this={ref}
class={cn("bg-muted animate-pulse rounded-md", className)}
{...restProps}
></div>

View file

@ -1,24 +1,24 @@
import {z} from "zod"; import { refinePasswords } from '$lib/validations/account';
import {refinePasswords} from "$lib/validations/account"; import { z } from 'zod';
export const signupUsernameEmailDto = z.object({ export const signupUsernameEmailDto = z
firstName: z.string().trim().optional(), .object({
lastName: z.string().trim().optional(), firstName: z.string().trim().optional(),
email: z.string() lastName: z.string().trim().optional(),
.trim() email: z
.max(64, {message: 'Email must be less than 64 characters'})
.email({message: 'Please enter a valid email'})
.optional(),
username: z
.string() .string()
.trim() .trim()
.min(3, {message: 'Must be at least 3 characters'}) .max(64, { message: 'Email must be less than 64 characters' })
.max(50, {message: 'Must be less than 50 characters'}), .refine((value) => !value || z.string().email().safeParse(value).success, {
password: z.string({required_error: 'Password is required'}).trim(), message: 'Please enter a valid email',
confirm_password: z.string({required_error: 'Confirm Password is required'}).trim() })
.optional(),
username: z.string().trim().min(3, { message: 'Must be at least 3 characters' }).max(50, { message: 'Must be less than 50 characters' }),
password: z.string({ required_error: 'Password is required' }).trim(),
confirm_password: z.string({ required_error: 'Confirm Password is required' }).trim(),
}) })
.superRefine(({ confirm_password, password }, ctx) => { .superRefine(({ confirm_password, password }, ctx) => {
return refinePasswords(confirm_password, password, ctx); return refinePasswords(confirm_password, password, ctx);
}); });
export type SignupUsernameEmailDto = z.infer<typeof signupUsernameEmailDto> export type SignupUsernameEmailDto = z.infer<typeof signupUsernameEmailDto>;

View file

@ -0,0 +1,27 @@
import { untrack } from "svelte";
const MOBILE_BREAKPOINT = 768;
export class IsMobile {
#current = $state<boolean>(false);
constructor() {
$effect(() => {
return untrack(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
this.#current = window.innerWidth < MOBILE_BREAKPOINT;
};
mql.addEventListener("change", onChange);
onChange();
return () => {
mql.removeEventListener("change", onChange);
};
});
});
}
get current() {
return this.#current;
}
}

3
src/lib/i18n.ts Normal file
View file

@ -0,0 +1,3 @@
import * as runtime from '$lib/paraglide/runtime';
import { createI18n } from '@inlang/paraglide-sveltekit';
export const i18n = createI18n(runtime);

View file

@ -1,24 +1,24 @@
import {z} from "zod"; import { refinePasswords } from '$lib/validations/account';
import {refinePasswords} from "$lib/validations/account"; import { z } from 'zod';
export const signupUsernameEmailDto = z.object({ export const signupUsernameEmailDto = z
firstName: z.string().trim().optional(), .object({
lastName: z.string().trim().optional(), firstName: z.string().trim().optional(),
email: z.string() lastName: z.string().trim().optional(),
.trim() email: z
.max(64, {message: 'Email must be less than 64 characters'})
.email({message: 'Please enter a valid email'})
.optional(),
username: z
.string() .string()
.trim() .trim()
.min(3, {message: 'Must be at least 3 characters'}) .max(64, { message: 'Email must be less than 64 characters' })
.max(50, {message: 'Must be less than 50 characters'}), .refine((value) => !value || z.string().email().safeParse(value).success, {
password: z.string({required_error: 'Password is required'}).trim(), message: 'Please enter a valid email',
confirm_password: z.string({required_error: 'Confirm Password is required'}).trim() })
.optional(),
username: z.string().trim().min(3, { message: 'Must be at least 3 characters' }).max(50, { message: 'Must be less than 50 characters' }),
password: z.string({ required_error: 'Password is required' }).trim(),
confirm_password: z.string({ required_error: 'Confirm Password is required' }).trim(),
}) })
.superRefine(({ confirm_password, password }, ctx) => { .superRefine(({ confirm_password, password }, ctx) => {
return refinePasswords(confirm_password, password, ctx); return refinePasswords(confirm_password, password, ctx);
}); });
export type SignupUsernameEmailDto = z.infer<typeof signupUsernameEmailDto> export type SignupUsernameEmailDto = z.infer<typeof signupUsernameEmailDto>;

View file

@ -1,43 +1,43 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$components/ui/button'; import { Button } from "$components/ui/button";
import * as Card from '$components/ui/card'; import * as Card from "$components/ui/card";
import * as Form from '$components/ui/form'; import * as Form from "$components/ui/form";
import { Input } from '$components/ui/input'; import { Input } from "$components/ui/input";
import { boredState } from '$lib/stores/boredState.js'; import { boredState } from "$lib/stores/boredState.js";
import { receive, send } from '$lib/utils/pageCrossfade'; import { receive, send } from "$lib/utils/pageCrossfade";
import * as flashModule from 'sveltekit-flash-message/client'; import * as flashModule from "sveltekit-flash-message/client";
import { superForm } from 'sveltekit-superforms/client'; import { superForm } from "sveltekit-superforms/client";
let { data } = $props(); let { data } = $props();
const superLoginForm = superForm(data.form, { const superLoginForm = superForm(data.form, {
onSubmit: () => boredState.update((n) => ({ ...n, loading: true })), onSubmit: () => boredState.update((n) => ({ ...n, loading: true })),
onResult: () => boredState.update((n) => ({ ...n, loading: false })), onResult: () => boredState.update((n) => ({ ...n, loading: false })),
flashMessage: { flashMessage: {
module: flashModule, module: flashModule,
onError: ({ result, flashMessage }) => { onError: ({ result, flashMessage }) => {
// Error handling for the flash message: // Error handling for the flash message:
// - result is the ActionResult // - result is the ActionResult
// - message is the flash store (not the status message store) // - message is the flash store (not the status message store)
const errorMessage = result.error.message; const errorMessage = result.error.message;
flashMessage.set({ type: 'error', message: errorMessage }); flashMessage.set({ type: "error", message: errorMessage });
},
}, },
}, syncFlashMessage: false,
syncFlashMessage: false, taintedMessage: null,
taintedMessage: null, // validators: zodClient(signInSchema),
// validators: zodClient(signInSchema), // validationMethod: 'oninput',
// validationMethod: 'oninput', delayMs: 0,
delayMs: 0, });
});
const { form: loginForm, enhance } = superLoginForm; const { form: loginForm, enhance } = superLoginForm;
</script> </script>
<svelte:head> <svelte:head>
<title>Bored Game | Login</title> <title>Bored Game | Login</title>
</svelte:head> </svelte:head>
<div in:receive={{ key: 'auth-card' }} out:send={{ key: 'auth-card' }}> <div in:receive={{ key: "auth-card" }} out:send={{ key: "auth-card" }}>
<Card.Root class="mx-auto mt-24 max-w-sm"> <Card.Root class="mx-auto mt-24 max-w-sm">
<Card.Header> <Card.Header>
<Card.Title class="text-2xl">Log into your account</Card.Title> <Card.Title class="text-2xl">Log into your account</Card.Title>
@ -48,13 +48,9 @@ const { form: loginForm, enhance } = superLoginForm;
{@render oAuthButtons()} {@render oAuthButtons()}
<p class="px-8 py-4 text-center text-sm text-muted-foreground"> <p class="px-8 py-4 text-center text-sm text-muted-foreground">
By clicking continue, you agree to our By clicking continue, you agree to our
<a href="/terms" class="underline underline-offset-4 hover:text-primary"> <a href="/terms" class="underline underline-offset-4 hover:text-primary"> Terms of Use </a>
Terms of Use
</a>
and and
<a href="/privacy" class="underline underline-offset-4 hover:text-primary"> <a href="/privacy" class="underline underline-offset-4 hover:text-primary"> Privacy Policy </a>.
Privacy Policy
</a>.
</p> </p>
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
@ -85,8 +81,24 @@ const { form: loginForm, enhance } = superLoginForm;
{#snippet oAuthButtons()} {#snippet oAuthButtons()}
<div class="grid gap-4"> <div class="grid gap-4">
<Button href="/login/google" variant="outline" class="w-full flex items-center gap-2"><svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google</title><path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"/></svg> Google</Button> <Button href="/login/google" variant="outline" class="w-full flex items-center gap-2">
<Button href="/login/github" variant="outline" class="w-full flex items-center gap-2"><svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg> GitHub</Button> <svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
><title>Google</title>
<path
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
/>
</svg>
Google
</Button>
<Button href="/login/github" variant="outline" class="w-full flex items-center gap-2">
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
><title>GitHub</title>
<path
d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
/>
</svg>
GitHub
</Button>
</div> </div>
{/snippet} {/snippet}
@ -95,4 +107,4 @@ const { form: loginForm, enhance } = superLoginForm;
width: 24px; width: 24px;
height: 24px; height: 24px;
} }
</style> </style>

View file

@ -42,7 +42,7 @@ export const load: PageServerLoad = async (event) => {
// } // }
return { return {
form: await superValidate(zod(signupUsernameEmailDto), { signupForm: await superValidate(zod(signupUsernameEmailDto), {
defaults: signUpDefaults, defaults: signUpDefaults,
}), }),
}; };

View file

@ -6,33 +6,23 @@ import * as Alert from '$lib/components/ui/alert';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import * as Collapsible from '$lib/components/ui/collapsible'; import * as Collapsible from '$lib/components/ui/collapsible';
import { signupUsernameEmailDto } from '$lib/dtos/signup-username-email.dto'; import { signupUsernameEmailDto } from '$lib/dtos/signup-username-email.dto';
import { boredState } from '$lib/stores/boredState.js';
import { receive, send } from '$lib/utils/pageCrossfade'; import { receive, send } from '$lib/utils/pageCrossfade';
import { ChevronsUpDown } from 'lucide-svelte'; import { ChevronsUpDown } from 'lucide-svelte';
import { quintIn } from 'svelte/easing'; import { quintIn } from 'svelte/easing';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import * as flashModule from 'sveltekit-flash-message/client'; import { superForm } from 'sveltekit-superforms';
import { zodClient } from 'sveltekit-superforms/adapters'; import { zodClient } from 'sveltekit-superforms/adapters';
import { superForm } from 'sveltekit-superforms/client';
export let data; const { data } = $props();
const { form, errors, enhance } = superForm(data.form, { const signupForm = superForm(data.signupForm, {
onSubmit: () => boredState.update((n) => ({ ...n, loading: true })),
onResult: () => boredState.update((n) => ({ ...n, loading: false })),
flashMessage: {
module: flashModule,
onError: ({ result, flashMessage }) => {
const errorMessage = result.error.message;
flashMessage.set({ type: 'error', message: errorMessage });
},
},
taintedMessage: null,
validators: zodClient(signupUsernameEmailDto), validators: zodClient(signupUsernameEmailDto),
delayMs: 0, resetForm: false,
}); });
let collapsibleOpen = false; const { form: signupFormData, errors: signupErrors, enhance: signupEnhance } = signupForm;
let collapsibleOpen = $state(false);
</script> </script>
<svelte:head> <svelte:head>
@ -45,26 +35,26 @@ let collapsibleOpen = false;
<Card.Title class="text-2xl">Signup for an account</Card.Title> <Card.Title class="text-2xl">Signup for an account</Card.Title>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<form method="POST" action="/signup" use:enhance class="grid gap-2 mt-4"> <form method="POST" action="/signup" use:signupEnhance class="grid gap-2 mt-4">
<Label for="username">Username <small>(required)</small></Label> <Label for="username">Username <small>(required)</small></Label>
<Input type="text" id="username" class={$errors.username && "outline outline-destructive"} name="username" <Input type="text" id="username" class={$signupErrors.username && "outline outline-destructive"} name="username"
placeholder="Username" autocomplete="username" data-invalid={$errors.username} bind:value={$form.username} /> placeholder="Username" autocomplete="username" data-invalid={$signupErrors.username} bind:value={$signupFormData.username} />
{#if $errors.username} {#if $signupErrors.username}
<p class="text-sm text-destructive">{$errors.username}</p> <p class="text-sm text-destructive">{$signupErrors.username}</p>
{/if} {/if}
<Label for="password">Password <small>(required)</small></Label> <Label for="password">Password <small>(required)</small></Label>
<Input type="password" id="password" class={$errors.password && "outline outline-destructive"} name="password" <Input type="password" id="password" class={$signupErrors.password && "outline outline-destructive"} name="password"
placeholder="Password" autocomplete="new-password" data-invalid={$errors.password} placeholder="Password" autocomplete="new-password" data-invalid={$signupErrors.password}
bind:value={$form.password} /> bind:value={$signupFormData.password} />
{#if $errors.password} {#if $signupErrors.password}
<p class="text-sm text-destructive">{$errors.password}</p> <p class="text-sm text-destructive">{$signupErrors.password}</p>
{/if} {/if}
<Label for="confirm_password">Confirm Password <small>(required)</small></Label> <Label for="confirm_password">Confirm Password <small>(required)</small></Label>
<Input type="password" id="confirm_password" class={$errors.confirm_password && "outline outline-destructive"} <Input type="password" id="confirm_password" class={$signupErrors.confirm_password && "outline outline-destructive"}
name="confirm_password" placeholder="Confirm Password" autocomplete="new-password" name="confirm_password" placeholder="Confirm Password" autocomplete="new-password"
data-invalid={$errors.confirm_password} bind:value={$form.confirm_password} /> data-invalid={$signupErrors.confirm_password} bind:value={$signupFormData.confirm_password} />
{#if $errors.confirm_password} {#if $signupErrors.confirm_password}
<p class="text-sm text-destructive">{$errors.confirm_password}</p> <p class="text-sm text-destructive">{$signupErrors.confirm_password}</p>
{/if} {/if}
<Collapsible.Root bind:open={collapsibleOpen} class="grid w-full max-w-sm items-center gap-2.5"> <Collapsible.Root bind:open={collapsibleOpen} class="grid w-full max-w-sm items-center gap-2.5">
<div> <div>
@ -79,32 +69,32 @@ let collapsibleOpen = false;
<Collapsible.Content> <Collapsible.Content>
<div transition:slide|global={{ delay: 10, duration: 150, easing: quintIn }}> <div transition:slide|global={{ delay: 10, duration: 150, easing: quintIn }}>
<Label for="email">Email</Label> <Label for="email">Email</Label>
<Input type="email" id="email" class={$errors.email && "outline outline-destructive"} name="email" <Input type="email" id="email" class={$signupErrors.email && "outline outline-destructive"} name="email"
placeholder="Email" autocomplete="email" data-invalid={$errors.email} bind:value={$form.email} /> placeholder="Email" autocomplete="email" data-invalid={$signupErrors.email} bind:value={$signupFormData.email} />
{#if $errors.email} {#if $signupErrors.email}
<p class="text-sm text-destructive">{$errors.email}</p> <p class="text-sm text-destructive">{$signupErrors.email}</p>
{/if} {/if}
</div> </div>
</Collapsible.Content> </Collapsible.Content>
<Collapsible.Content> <Collapsible.Content>
<div transition:slide|global={{ delay: 10, duration: 150, easing: quintIn }}> <div transition:slide|global={{ delay: 10, duration: 150, easing: quintIn }}>
<Label for="firstName">First Name</Label> <Label for="firstName">First Name</Label>
<Input type="text" id="firstName" class={$errors.firstName && "outline outline-destructive"} name="firstName" <Input type="text" id="firstName" class={$signupErrors.firstName && "outline outline-destructive"} name="firstName"
placeholder="First Name" autocomplete="given-name" data-invalid={$errors.firstName} placeholder="First Name" autocomplete="given-name" data-invalid={$signupErrors.firstName}
bind:value={$form.firstName} /> bind:value={$signupFormData.firstName} />
{#if $errors.firstName} {#if $signupErrors.firstName}
<p class="text-sm text-destructive">{$errors.firstName}</p> <p class="text-sm text-destructive">{$signupErrors.firstName}</p>
{/if} {/if}
</div> </div>
</Collapsible.Content> </Collapsible.Content>
<Collapsible.Content> <Collapsible.Content>
<div transition:slide|global={{ delay: 10, duration: 150, easing: quintIn }}> <div transition:slide|global={{ delay: 10, duration: 150, easing: quintIn }}>
<Label for="firstName">Last Name</Label> <Label for="firstName">Last Name</Label>
<Input type="text" id="lastName" class={$errors.firstName && "outline outline-destructive"} name="lastName" <Input type="text" id="lastName" class={$signupErrors.firstName && "outline outline-destructive"} name="lastName"
placeholder="Last Name" autocomplete="family-name" data-invalid={$errors.lastName} placeholder="Last Name" autocomplete="family-name" data-invalid={$signupErrors.lastName}
bind:value={$form.lastName} /> bind:value={$signupFormData.lastName} />
{#if $errors.lastName} {#if $signupErrors.lastName}
<p class="text-sm text-destructive">{$errors.lastName}</p> <p class="text-sm text-destructive">{$signupErrors.lastName}</p>
{/if} {/if}
</div> </div>
</Collapsible.Content> </Collapsible.Content>
@ -113,7 +103,7 @@ let collapsibleOpen = false;
<Button type="submit">Signup</Button> <Button type="submit">Signup</Button>
<Button variant="link" class="text-secondary-foreground" href="/">or Cancel</Button> <Button variant="link" class="text-secondary-foreground" href="/">or Cancel</Button>
</div> </div>
{#if !$form.email} {#if !$signupFormData.email}
<Alert.Root> <Alert.Root>
<Alert.Title level="h3">Heads up!</Alert.Title> <Alert.Title level="h3">Heads up!</Alert.Title>
<Alert.Description> <Alert.Description>
@ -128,41 +118,4 @@ let collapsibleOpen = false;
</div> </div>
<style lang="postcss"> <style lang="postcss">
.sign-up {
display: flex;
margin-top: 1.5rem;
flex-direction: column;
justify-content: center;
width: 100%;
margin-right: auto;
margin-left: auto;
@media (min-width: 640px) {
width: 350px;
}
form {
display: grid;
gap: 0.5rem;
align-items: center;
max-width: 24rem;
h2:first-child {
margin-top: 0;
}
h2 {
padding-bottom: 0.5rem;
border-bottom-width: 1px;
font-size: 1.875rem;
line-height: 2.25rem;
font-weight: 600;
letter-spacing: -0.025em;
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
scroll-margin: 5rem;
}
}
}
</style> </style>

View file

@ -1,67 +1,69 @@
<script lang="ts"> <script lang="ts">
import '$lib/styles/app.pcss'; import '$lib/styles/app.pcss';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { MetaTags } from 'svelte-meta-tags'; import { MetaTags } from 'svelte-meta-tags';
import { getFlash } from 'sveltekit-flash-message/client'; import { getFlash } from 'sveltekit-flash-message/client';
import 'iconify-icon'; import { ParaglideJS } from '@inlang/paraglide-sveltekit';
import { onNavigate } from '$app/navigation'; import 'iconify-icon';
import { page } from '$app/stores'; import { onNavigate } from '$app/navigation';
import Analytics from '$components/Analytics.svelte'; import { page } from '$app/stores';
import { Toaster } from '$lib/components/ui/sonner'; import Analytics from '$components/Analytics.svelte';
import PageLoadingIndicator from '$lib/page_loading_indicator.svelte'; import { Toaster } from '$lib/components/ui/sonner';
import { toastMessage } from '$lib/utils/superforms.js'; import PageLoadingIndicator from '$lib/page_loading_indicator.svelte';
import { theme } from '$state/theme'; import { toastMessage } from '$lib/utils/superforms.js';
// import { ModeWatcher } from 'mode-watcher' import { theme } from '$state/theme';
import { i18n } from '$lib/i18n';
// import { ModeWatcher } from 'mode-watcher'
const dev = process.env.NODE_ENV !== 'production'; const dev = process.env.NODE_ENV !== 'production';
const { data, children } = $props(); const { data, children } = $props();
const { user } = data; const { user } = data;
const metaTags = $derived({ const metaTags = $derived({
titleTemplate: '%s | Bored Game',
description: 'Bored Game, keep track of your games.',
openGraph: {
type: 'website',
titleTemplate: '%s | Bored Game', titleTemplate: '%s | Bored Game',
locale: 'en_US', description: 'Bored Game, keep track of your games.',
description: 'Bored Game, keep track of your games', openGraph: {
}, type: 'website',
...$page.data.metaTagsChild, titleTemplate: '%s | Bored Game',
}); locale: 'en_US',
description: 'Bored Game, keep track of your games',
},
...$page.data.metaTagsChild,
});
const flash = getFlash(page, { const flash = getFlash(page, {
clearOnNavigate: true, clearOnNavigate: true,
clearAfterMs: 3000, clearAfterMs: 3000,
clearArray: true, clearArray: true,
}); });
onMount(() => { onMount(() => {
// set the theme to the user's active theme // set the theme to the user's active theme
$theme = user?.theme || 'system'; $theme = user?.theme || 'system';
document.querySelector('html')?.setAttribute('data-theme', $theme); document.querySelector('html')?.setAttribute('data-theme', $theme);
}); });
$effect(() => { $effect(() => {
console.log('flash', $flash); console.log('flash', $flash);
if ($flash) { if ($flash) {
toastMessage({ type: $flash.type, text: $flash.message }); toastMessage({ type: $flash.type, text: $flash.message });
// Clearing the flash message could sometimes // Clearing the flash message could sometimes
// be required here to avoid double-toasting. // be required here to avoid double-toasting.
flash.set(undefined); flash.set(undefined);
} }
}); });
onNavigate(async (navigation) => { onNavigate(async (navigation) => {
if (!document.startViewTransition) return; if (!document.startViewTransition) return;
return new Promise((oldStateCaptureResolve) => { return new Promise((oldStateCaptureResolve) => {
document.startViewTransition(async () => { document.startViewTransition(async () => {
oldStateCaptureResolve(); oldStateCaptureResolve();
await navigation.complete; await navigation.complete;
});
}); });
}); });
});
</script> </script>
{#if !dev} {#if !dev}
@ -72,4 +74,7 @@ onNavigate(async (navigation) => {
<PageLoadingIndicator /> <PageLoadingIndicator />
<!-- <ModeWatcher /> --> <!-- <ModeWatcher /> -->
<Toaster /> <Toaster />
{@render children()}
<ParaglideJS {i18n}>
{@render children()}
</ParaglideJS>

View file

@ -1,4 +1,3 @@
import 'reflect-metadata'
import { preprocessMeltUI } from '@melt-ui/pp' import { preprocessMeltUI } from '@melt-ui/pp'
import adapter from '@sveltejs/adapter-node' import adapter from '@sveltejs/adapter-node'
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'

View file

@ -1,10 +1,11 @@
import { fontFamily } from "tailwindcss/defaultTheme"; import { fontFamily } from "tailwindcss/defaultTheme";
import type { Config } from "tailwindcss";
import tailwindcssAnimate from "tailwindcss-animate"; import tailwindcssAnimate from "tailwindcss-animate";
/** @type {import('tailwindcss').Config} */ const config: Config = {
const config = {
darkMode: ["class"], darkMode: ["class"],
content: ["./src/**/*.{html,js,svelte,ts}"], content: ["./src/**/*.{html,js,svelte,ts}"],
safelist: ["dark"],
theme: { theme: {
container: { container: {
center: true, center: true,
@ -47,16 +48,46 @@ const config = {
card: { card: {
DEFAULT: "hsl(var(--card))", DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))" foreground: "hsl(var(--card-foreground))"
} },
sidebar: {
DEFAULT: "hsl(var(--sidebar-background))",
foreground: "hsl(var(--sidebar-foreground))",
primary: "hsl(var(--sidebar-primary))",
"primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: "hsl(var(--sidebar-accent))",
"accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
}, },
borderRadius: { borderRadius: {
xl: "calc(var(--radius) + 4px)",
lg: "var(--radius)", lg: "var(--radius)",
md: "calc(var(--radius) - 2px)", md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)" sm: "calc(var(--radius) - 4px)"
}, },
fontFamily: { fontFamily: {
sans: [...fontFamily.sans] sans: [...fontFamily.sans]
} },
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--bits-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--bits-accordion-content-height)" },
to: { height: "0" },
},
"caret-blink": {
"0%,70%,100%": { opacity: "1" },
"20%,50%": { opacity: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"caret-blink": "caret-blink 1.25s ease-out infinite",
},
} }
}, },
plugins: [tailwindcssAnimate] plugins: [tailwindcssAnimate]

View file

@ -17,7 +17,8 @@
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
} }
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
// //
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in // from the referenced tsconfig.json - TypeScript does not merge them in

View file

@ -1,19 +1,13 @@
// import { sentrySvelteKit } from "@sentry/sveltekit"; import { paraglide } from "@inlang/paraglide-sveltekit/vite";
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { sveltekit } from '@sveltejs/kit/vite';
// TODO: Fix Sentry
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
// sentrySvelteKit({ sveltekit(), paraglide({
// sourceMapsUploadOptions: { project: "./project.inlang",
// org: process.env.SENTRY_ORG, outdir: "./src/lib/paraglide"
// project: process.env.SENTRY_PROJECT, }),
// authToken: process.env.SENTRY_AUTH_TOKEN,
// cleanArtifacts: true,
// }
// }),
sveltekit(),
], ],
esbuild: { esbuild: {
target: 'es2022', target: 'es2022',