first commit

This commit is contained in:
Leandro Afonso
2025-10-19 20:47:58 +01:00
commit 63f850d705
80 changed files with 8358 additions and 0 deletions

24
apps/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
test-results
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
apps/web/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

4
apps/web/.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock

15
apps/web/.prettierrc Normal file
View File

@@ -0,0 +1,15 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

38
apps/web/README.md Normal file
View File

@@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

17
apps/web/components.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://next.shadcn-svelte.com/schema.json",
"style": "default",
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app.css",
"baseColor": "neutral"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks"
},
"typescript": true,
"registry": "https://next.shadcn-svelte.com/registry"
}

View File

@@ -0,0 +1,6 @@
import { expect, test } from '@playwright/test';
test('home page has expected h1', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toBeVisible();
});

51
apps/web/package.json Normal file
View File

@@ -0,0 +1,51 @@
{
"name": "web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"postbuild": "bun postbuild.ts",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.45.3",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@types/bun": "^1.1.14",
"autoprefixer": "^10.4.20",
"bits-ui": "^1.0.0-next.78",
"clsx": "^2.1.1",
"formsnap": "^2.0.0",
"lucide-svelte": "^0.469.0",
"prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.6",
"prettier-plugin-tailwindcss": "^0.6.5",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-sonner": "^0.3.28",
"sveltekit-superforms": "^2.22.1",
"tailwind-merge": "^2.5.5",
"tailwind-variants": "^0.3.0",
"tailwindcss": "^3.4.9",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.0.0",
"vite": "^5.4.11",
"vitest": "^2.0.4",
"zod": "^3.24.1"
},
"dependencies": {
"@formkit/auto-animate": "^0.8.2",
"mode-watcher": "^0.5.0",
"pocketbase": "^0.24.0"
}
}

View File

@@ -0,0 +1,10 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
webServer: {
command: 'npm run build && npm run preview',
port: 4173
},
testDir: 'e2e'
});

6
apps/web/postbuild.ts Normal file
View File

@@ -0,0 +1,6 @@
import { $ } from 'bun';
import fs from 'fs';
// copy build folder to ../server/pb_public
await $`rm -rf ../server/pb_public`;
fs.cpSync('build', '../server/pb_public', { recursive: true });

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

89
apps/web/src/app.css Normal file
View File

@@ -0,0 +1,89 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 0 0% 98%;
--ring: 0 0% 3.9%;
--radius: 0.75rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 222 47% 4%;
--foreground: 210 40% 98%;
--muted: 217 33% 12%;
--muted-foreground: 215 20% 65%;
--popover: 222 47% 6%;
--popover-foreground: 210 40% 98%;
--card: 222 47% 6%;
--card-foreground: 210 40% 98%;
--border: 217 33% 15%;
--input: 217 33% 15%;
--primary: 210 100% 60%;
--primary-foreground: 222 47% 4%;
--secondary: 217 33% 12%;
--secondary-foreground: 210 40% 98%;
--accent: 217 33% 17%;
--accent-foreground: 210 40% 98%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--ring: 210 100% 60%;
--sidebar-background: 222 47% 5%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 210 100% 60%;
--sidebar-primary-foreground: 222 47% 4%;
--sidebar-accent: 217 33% 12%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217 33% 12%;
--sidebar-ring: 210 100% 60%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
}
}
@layer utilities {
.glass-effect {
@apply backdrop-blur-xl bg-card/50 border-border/50;
}
.glow-primary {
box-shadow: 0 0 20px -5px hsl(var(--primary) / 0.3);
}
.glow-success {
box-shadow: 0 0 20px -5px hsl(142 76% 36% / 0.3);
}
}

13
apps/web/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
apps/web/src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

View File

@@ -0,0 +1,155 @@
<script lang="ts">
import { superForm, defaults } from 'sveltekit-superforms';
import SuperDebug from 'sveltekit-superforms';
import { zodClient, zod } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
import * as Form from '$lib/components/ui/form/index.js';
import { Input } from './ui/input';
import { toast } from 'svelte-sonner';
import { cn } from '$lib/utils';
import { pb } from '$lib/pb';
import { dev } from '$app/environment';
import { hostsStore } from '$lib/stores/hosts';
import * as Popover from '$lib/components/ui/popover/index';
import { Button } from './ui/button';
import { Icon, InfoIcon } from 'lucide-svelte';
const formSchema = z.object({
name: z.string().min(1, 'Name is required'),
mac: z
.string()
.min(1, 'MAC is required')
.regex(/^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$/, 'Invalid MAC address format'),
ip: z.string().min(1, 'IP is required').ip(),
port: z.number().default(9)
});
const form = superForm(defaults(zod(formSchema)), {
validators: zodClient(formSchema),
SPA: true,
onUpdate({ form, cancel }) {
if (!form.valid) {
toast.error('Invalid');
return;
}
if (!pb.authStore.record) {
toast.error('You must be logged in to create a host');
return;
}
hostsStore.createHost({ ...form.data, user: pb.authStore.record?.id });
toast.success('Host created');
cancel();
}
});
const { form: formData, enhance, errors } = form;
let { class: className }: { class?: string } = $props();
</script>
<form method="POST" use:enhance class={cn('grid grid-cols-1 md:grid-cols-2 gap-4', className)}>
<Form.Field {form} name="name">
<Form.Control>
{#snippet children({ props })}
<div class="space-y-2">
<Form.Label class="text-sm font-medium">Device Name</Form.Label>
<Input
{...props}
name="name"
bind:value={$formData.name}
placeholder="e.g., TrueNAS, Gaming PC"
class="h-11"
/>
</div>
{/snippet}
</Form.Control>
<Form.FieldErrors class="text-xs" />
</Form.Field>
<Form.Field {form} name="mac">
<Form.Control>
{#snippet children({ props })}
<div class="space-y-2">
<Form.Label class="text-sm font-medium">MAC Address</Form.Label>
<Input
{...props}
name="mac"
bind:value={$formData.mac}
placeholder="86:2f:57:c1:df:65"
class="h-11 font-mono"
/>
</div>
{/snippet}
</Form.Control>
<Form.FieldErrors class="text-xs" />
</Form.Field>
<Form.Field {form} name="ip">
<Form.Control>
{#snippet children({ props })}
<div class="space-y-2">
<Form.Label class="text-sm font-medium">Broadcast IP</Form.Label>
<div class="flex space-x-2">
<Input
{...props}
name="ip"
bind:value={$formData.ip}
placeholder="255.255.255.255"
class="h-11 font-mono flex-1"
/>
<Popover.Root>
<Popover.Trigger>
<Button variant="secondary" size="icon" class="h-11 w-11 flex-shrink-0">
<InfoIcon class="h-4 w-4" />
</Button>
</Popover.Trigger>
<Popover.Content class="w-80 glass-effect">
<div class="space-y-2">
<h4 class="font-semibold text-sm">Broadcast IP Guide</h4>
<p class="text-sm text-muted-foreground">
Use a broadcast IP address. Default is <code class="px-1 py-0.5 bg-muted rounded text-xs">255.255.255.255</code>
</p>
<p class="text-sm text-muted-foreground">
For multiple networks, use a subnet-specific broadcast IP.
</p>
<p class="text-sm text-muted-foreground">
Example: If host IP is <code class="px-1 py-0.5 bg-muted rounded text-xs">192.168.1.123</code>, use <code class="px-1 py-0.5 bg-muted rounded text-xs">192.168.1.255</code>
</p>
</div>
</Popover.Content>
</Popover.Root>
</div>
</div>
{/snippet}
</Form.Control>
<Form.FieldErrors class="text-xs" />
</Form.Field>
<Form.Field {form} name="port">
<Form.Control>
{#snippet children({ props })}
<div class="space-y-2">
<Form.Label class="text-sm font-medium">Port</Form.Label>
<Input
{...props}
name="port"
bind:value={$formData.port}
placeholder="9"
class="h-11 font-mono"
type="number"
/>
</div>
{/snippet}
</Form.Control>
<Form.FieldErrors class="text-xs" />
</Form.Field>
<div class="md:col-span-2">
<Form.Button class="w-full h-11 bg-primary hover:bg-primary/90 font-semibold">
Add Device
</Form.Button>
</div>
{#if dev}
<!-- <SuperDebug data={$formData} /> -->
{/if}
</form>

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card/index.js';
import type { HostsRecord } from '$lib/pocketbase-types';
import { cn } from '$lib/utils';
import { Trash2, Zap, Network, Hash, Radio } from 'lucide-svelte';
import { Button } from './ui/button';
import { hostsStore } from '$lib/stores/hosts';
let { host, class: className }: { host: HostsRecord; class?: string } = $props();
function wake() {
hostsStore.wakeHost(host);
}
function deleteHost() {
if (window.confirm(`Are you sure you want to delete "${host.name}"?`)) {
hostsStore.deleteHost(host);
}
}
</script>
<Card.Root class={cn('relative overflow-hidden group hover:shadow-xl transition-all duration-300 glass-effect', className)}>
<!-- Gradient accent on hover -->
<div class="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"></div>
<Card.Header class="pb-3">
<div class="flex items-start justify-between">
<div class="flex items-center space-x-3">
<div class="w-12 h-12 rounded-xl bg-primary/10 flex items-center justify-center glow-primary">
<Network class="h-6 w-6 text-primary" />
</div>
<div>
<h3 class="text-xl font-semibold">{host.name}</h3>
<p class="text-sm text-muted-foreground">Network Device</p>
</div>
</div>
<Button
size="icon"
variant="ghost"
class="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
onclick={deleteHost}
>
<Trash2 class="h-4 w-4" />
</Button>
</div>
</Card.Header>
<Card.Content class="space-y-3">
<div class="grid gap-3">
<div class="flex items-center space-x-3 p-2 rounded-lg bg-muted/50">
<Hash class="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-xs text-muted-foreground">MAC Address</p>
<p class="font-mono text-sm font-medium truncate">{host.mac}</p>
</div>
</div>
<div class="flex items-center space-x-3 p-2 rounded-lg bg-muted/50">
<Radio class="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-xs text-muted-foreground">Broadcast IP</p>
<p class="font-mono text-sm font-medium truncate">{host.ip}</p>
</div>
</div>
<div class="flex items-center space-x-3 p-2 rounded-lg bg-muted/50">
<Network class="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-xs text-muted-foreground">Port</p>
<p class="font-mono text-sm font-medium">{host.port}</p>
</div>
</div>
</div>
</Card.Content>
<Card.Footer class="pt-4">
<Button
class="w-full bg-gradient-to-r from-green-600 to-green-500 hover:from-green-500 hover:to-green-400 text-white font-semibold shadow-lg glow-success transition-all duration-300 transform hover:scale-[1.02]"
onclick={wake}
>
<Zap class="h-4 w-4 mr-2" />
Wake Device
</Button>
</Card.Footer>
</Card.Root>

View File

@@ -0,0 +1,84 @@
<script>
import { Button } from './ui/button';
import { pb } from '$lib/pb';
import { cn } from '$lib/utils';
import { page } from '$app/state';
import { LogOut, Zap, Home, Lock } from 'lucide-svelte';
import { goto } from '$app/navigation';
function logout() {
pb.authStore.clear();
goto('/auth');
}
let isAuth = $state(pb.authStore.isValid);
$effect(() => {
page.url.pathname;
isAuth = pb.authStore.isValid;
});
</script>
<nav
class="fixed top-0 w-full z-50 backdrop-blur-xl bg-background/70 border-b border-border/50"
>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<!-- Logo -->
<div class="flex items-center space-x-2">
<div class="flex items-center justify-center w-10 h-10 rounded-lg bg-primary/10 glow-primary">
<Zap class="h-5 w-5 text-primary" />
</div>
<div class="flex flex-col">
<span class="font-bold text-lg leading-none">WoL Manager</span>
<span class="text-xs text-muted-foreground leading-none">Wake-on-LAN</span>
</div>
</div>
<!-- Navigation Links -->
<div class="flex items-center space-x-1">
<a
href="/"
class={cn(
'flex items-center space-x-2 px-4 py-2 rounded-lg transition-all duration-200',
page.url.pathname === '/'
? 'bg-primary text-primary-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
>
<Home class="h-4 w-4" />
<span>Home</span>
</a>
<a
href="/auth"
class={cn(
'flex items-center space-x-2 px-4 py-2 rounded-lg transition-all duration-200',
page.url.pathname === '/auth'
? 'bg-primary text-primary-foreground font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
>
<Lock class="h-4 w-4" />
<span>Auth</span>
</a>
</div>
<!-- User Actions -->
<div>
{#if isAuth}
<Button
size="sm"
variant="outline"
class="group hover:border-destructive hover:text-destructive transition-colors"
onclick={logout}
>
<LogOut class="h-4 w-4 mr-2" />
<span class="hidden sm:inline">Logout</span>
</Button>
{:else}
<div class="w-20"></div>
{/if}
</div>
</div>
</div>
</nav>

View File

@@ -0,0 +1,74 @@
<script lang="ts" module>
import type { WithElementRef } from "bits-ui";
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
import { type VariantProps, tv } from "tailwind-variants";
export const buttonVariants = tv({
base: "ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border-input bg-background hover:bg-accent hover:text-accent-foreground border",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
import { cn } from "$lib/utils.js";
let {
class: className,
variant = "default",
size = "default",
ref = $bindable(null),
href = undefined,
type = "button",
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
class={cn(buttonVariants({ variant, size, className }))}
{href}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
class={cn(buttonVariants({ variant, size, className }))}
{type}
{...restProps}
>
{@render children?.()}
</button>
{/if}

View File

@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
level = 3,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
level?: 1 | 2 | 3 | 4 | 5 | 6;
} = $props();
</script>
<div
role="heading"
aria-level={level}
bind:this={ref}
class={cn("text-2xl font-semibold leading-none tracking-tight", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { WithElementRef } from "bits-ui";
import type { HTMLAttributes } from "svelte/elements";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
class={cn("bg-card text-card-foreground rounded-lg border shadow-sm", className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,22 @@
import Root from "./card.svelte";
import Content from "./card-content.svelte";
import Description from "./card-description.svelte";
import Footer from "./card-footer.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
export {
Root,
Content,
Description,
Footer,
Header,
Title,
//
Root as Card,
Content as CardContent,
Description as CardDescription,
Footer as CardFooter,
Header as CardHeader,
Title as CardTitle,
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import * as Button from "$lib/components/ui/button/index.js";
let { ref = $bindable(null), ...restProps }: Button.Props = $props();
</script>
<Button.Root bind:ref type="submit" {...restProps} />

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import * as FormPrimitive from "formsnap";
import type { WithoutChild } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChild<FormPrimitive.DescriptionProps> = $props();
</script>
<FormPrimitive.Description
bind:ref
class={cn("text-muted-foreground text-sm", className)}
{...restProps}
/>

View File

@@ -0,0 +1,30 @@
<script lang="ts" module>
import type { FormPathLeaves as _FormPathLeaves } from "sveltekit-superforms";
type T = Record<string, unknown>;
type U = _FormPathLeaves<T>;
</script>
<script lang="ts" generics="T extends Record<string, unknown>, U extends _FormPathLeaves<T>">
import * as FormPrimitive from "formsnap";
import type { HTMLAttributes } from "svelte/elements";
import type { WithElementRef, WithoutChildren } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
form,
name,
children: childrenProp,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> &
FormPrimitive.ElementFieldProps<T, U> = $props();
</script>
<FormPrimitive.ElementField {form} {name}>
{#snippet children({ constraints, errors, tainted, value })}
<div bind:this={ref} class={cn("space-y-2", className)} {...restProps}>
{@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })}
</div>
{/snippet}
</FormPrimitive.ElementField>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import * as FormPrimitive from "formsnap";
import type { WithoutChild } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
errorClasses,
children: childrenProp,
...restProps
}: WithoutChild<FormPrimitive.FieldErrorsProps> & {
errorClasses?: string | undefined | null;
} = $props();
</script>
<FormPrimitive.FieldErrors
bind:ref
class={cn("text-destructive text-sm font-medium", className)}
{...restProps}
>
{#snippet children({ errors, errorProps })}
{#if childrenProp}
{@render childrenProp({ errors, errorProps })}
{:else}
{#each errors as error}
<div {...errorProps} class={cn(errorClasses)}>{error}</div>
{/each}
{/if}
{/snippet}
</FormPrimitive.FieldErrors>

View File

@@ -0,0 +1,30 @@
<script lang="ts" module>
import type { FormPath as _FormPath } from "sveltekit-superforms";
type T = Record<string, unknown>;
type U = _FormPath<T>;
</script>
<script lang="ts" generics="T extends Record<string, unknown>, U extends _FormPath<T>">
import * as FormPrimitive from "formsnap";
import type { WithoutChildren, WithElementRef } from "bits-ui";
import { cn } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
form,
name,
children: childrenProp,
...restProps
}: FormPrimitive.FieldProps<T, U> &
WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
</script>
<FormPrimitive.Field {form} {name}>
{#snippet children({ constraints, errors, tainted, value })}
<div bind:this={ref} class={cn("space-y-2", className)} {...restProps}>
{@render childrenProp?.({ constraints, errors, tainted, value: value as T[U] })}
</div>
{/snippet}
</FormPrimitive.Field>

View File

@@ -0,0 +1,21 @@
<script lang="ts" module>
import type { FormPath as _FormPath } from "sveltekit-superforms";
type T = Record<string, unknown>;
type U = _FormPath<T>;
</script>
<script lang="ts" generics="T extends Record<string, unknown>, U extends _FormPath<T>">
import * as FormPrimitive from "formsnap";
import type { WithoutChild } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
form,
name,
...restProps
}: WithoutChild<FormPrimitive.FieldsetProps<T, U>> = $props();
</script>
<FormPrimitive.Fieldset bind:ref {form} {name} class={cn("space-y-2", className)} {...restProps} />

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import type { WithoutChild } from "bits-ui";
import * as FormPrimitive from "formsnap";
import { Label } from "$lib/components/ui/label/index.js";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
children,
class: className,
...restProps
}: WithoutChild<FormPrimitive.LabelProps> = $props();
</script>
<FormPrimitive.Label {...restProps} bind:ref>
{#snippet child({ props })}
<Label {...props} class={cn("data-[fs-error]:text-destructive", className)}>
{@render children?.()}
</Label>
{/snippet}
</FormPrimitive.Label>

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import * as FormPrimitive from "formsnap";
import type { WithoutChild } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChild<FormPrimitive.LegendProps> = $props();
</script>
<FormPrimitive.Legend
bind:ref
class={cn("data-[fs-error]:text-destructive text-sm font-medium leading-none", className)}
{...restProps}
/>

View File

@@ -0,0 +1,33 @@
import * as FormPrimitive from "formsnap";
import Description from "./form-description.svelte";
import Label from "./form-label.svelte";
import FieldErrors from "./form-field-errors.svelte";
import Field from "./form-field.svelte";
import Fieldset from "./form-fieldset.svelte";
import Legend from "./form-legend.svelte";
import ElementField from "./form-element-field.svelte";
import Button from "./form-button.svelte";
const Control = FormPrimitive.Control;
export {
Field,
Control,
Label,
Button,
FieldErrors,
Description,
Fieldset,
Legend,
ElementField,
//
Field as FormField,
Control as FormControl,
Description as FormDescription,
Label as FormLabel,
FieldErrors as FormFieldErrors,
Fieldset as FormFieldset,
Legend as FormLegend,
ElementField as FormElementField,
Button as FormButton,
};

View File

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

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import type { HTMLInputAttributes } from "svelte/elements";
import type { WithElementRef } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(),
class: className,
...restProps
}: WithElementRef<HTMLInputAttributes> = $props();
</script>
<input
bind:this={ref}
class={cn(
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-base file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
bind:value
{...restProps}
/>

View File

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

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Label as LabelPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
class={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,17 @@
import { Popover as PopoverPrimitive } from "bits-ui";
import Content from "./popover-content.svelte";
const Root = PopoverPrimitive.Root;
const Trigger = PopoverPrimitive.Trigger;
const Close = PopoverPrimitive.Close;
export {
Root,
Content,
Trigger,
Close,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose,
};

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import { Popover as PopoverPrimitive } from "bits-ui";
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
align = "center",
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: PopoverPrimitive.PortalProps;
} = $props();
</script>
<PopoverPrimitive.Portal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
{sideOffset}
{align}
class={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-none",
className
)}
{...restProps}
/>
</PopoverPrimitive.Portal>

View File

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

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
orientation = "horizontal",
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
class={cn(
"bg-border shrink-0",
orientation === "horizontal" ? "h-[1px] w-full" : "min-h-full w-[1px]",
className
)}
{orientation}
{...restProps}
/>

View File

@@ -0,0 +1 @@
export { default as Toaster } from "./sonner.svelte";

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Toaster as Sonner, type ToasterProps as SonnerProps } from "svelte-sonner";
import { mode } from "mode-watcher";
let restProps: SonnerProps = $props();
</script>
<Sonner
theme={$mode}
class="toaster group"
toastOptions={{
classes: {
toast: "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...restProps}
/>

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

5
apps/web/src/lib/pb.ts Normal file
View File

@@ -0,0 +1,5 @@
import PocketBase from 'pocketbase';
import type { TypedPocketBase } from './pocketbase-types';
import { dev } from '$app/environment';
export const pb = new PocketBase(dev ? 'http://localhost:8090' : undefined) as TypedPocketBase;

View File

@@ -0,0 +1,155 @@
/**
* This file was @generated using pocketbase-typegen
*/
import type PocketBase from 'pocketbase'
import type { RecordService } from 'pocketbase'
export enum Collections {
Authorigins = "_authOrigins",
Externalauths = "_externalAuths",
Mfas = "_mfas",
Otps = "_otps",
Superusers = "_superusers",
Hosts = "hosts",
Users = "users",
}
// Alias types for improved usability
export type IsoDateString = string
export type RecordIdString = string
export type HTMLString = string
// System fields
export type BaseSystemFields<T = never> = {
id: RecordIdString
collectionId: string
collectionName: Collections
expand?: T
}
export type AuthSystemFields<T = never> = {
email: string
emailVisibility: boolean
username: string
verified: boolean
} & BaseSystemFields<T>
// Record types for each collection
export type AuthoriginsRecord = {
collectionRef: string
created?: IsoDateString
fingerprint: string
id: string
recordRef: string
updated?: IsoDateString
}
export type ExternalauthsRecord = {
collectionRef: string
created?: IsoDateString
id: string
provider: string
providerId: string
recordRef: string
updated?: IsoDateString
}
export type MfasRecord = {
collectionRef: string
created?: IsoDateString
id: string
method: string
recordRef: string
updated?: IsoDateString
}
export type OtpsRecord = {
collectionRef: string
created?: IsoDateString
id: string
password: string
recordRef: string
sentTo?: string
updated?: IsoDateString
}
export type SuperusersRecord = {
created?: IsoDateString
email: string
emailVisibility?: boolean
id: string
password: string
tokenKey: string
updated?: IsoDateString
verified?: boolean
}
export type HostsRecord = {
created?: IsoDateString
id: string
ip: string
mac: string
name: string
port: number
updated?: IsoDateString
user: RecordIdString
}
export type UsersRecord = {
avatar?: string
created?: IsoDateString
email: string
emailVisibility?: boolean
id: string
name?: string
password: string
tokenKey: string
updated?: IsoDateString
verified?: boolean
}
// Response types include system fields and match responses from the PocketBase API
export type AuthoriginsResponse<Texpand = unknown> = Required<AuthoriginsRecord> & BaseSystemFields<Texpand>
export type ExternalauthsResponse<Texpand = unknown> = Required<ExternalauthsRecord> & BaseSystemFields<Texpand>
export type MfasResponse<Texpand = unknown> = Required<MfasRecord> & BaseSystemFields<Texpand>
export type OtpsResponse<Texpand = unknown> = Required<OtpsRecord> & BaseSystemFields<Texpand>
export type SuperusersResponse<Texpand = unknown> = Required<SuperusersRecord> & AuthSystemFields<Texpand>
export type HostsResponse<Texpand = unknown> = Required<HostsRecord> & BaseSystemFields<Texpand>
export type UsersResponse<Texpand = unknown> = Required<UsersRecord> & AuthSystemFields<Texpand>
// Types containing all Records and Responses, useful for creating typing helper functions
export type CollectionRecords = {
_authOrigins: AuthoriginsRecord
_externalAuths: ExternalauthsRecord
_mfas: MfasRecord
_otps: OtpsRecord
_superusers: SuperusersRecord
hosts: HostsRecord
users: UsersRecord
}
export type CollectionResponses = {
_authOrigins: AuthoriginsResponse
_externalAuths: ExternalauthsResponse
_mfas: MfasResponse
_otps: OtpsResponse
_superusers: SuperusersResponse
hosts: HostsResponse
users: UsersResponse
}
// Type for usage with type asserted PocketBase instance
// https://github.com/pocketbase/js-sdk#specify-typescript-definitions
export type TypedPocketBase = PocketBase & {
collection(idOrName: '_authOrigins'): RecordService<AuthoriginsResponse>
collection(idOrName: '_externalAuths'): RecordService<ExternalauthsResponse>
collection(idOrName: '_mfas'): RecordService<MfasResponse>
collection(idOrName: '_otps'): RecordService<OtpsResponse>
collection(idOrName: '_superusers'): RecordService<SuperusersResponse>
collection(idOrName: 'hosts'): RecordService<HostsResponse>
collection(idOrName: 'users'): RecordService<UsersResponse>
}

View File

@@ -0,0 +1,64 @@
import { pb } from '$lib/pb';
import type { HostsRecord, RecordIdString } from '$lib/pocketbase-types';
import { toast } from 'svelte-sonner';
import { writable } from 'svelte/store';
export function createHostsStore() {
const hosts = writable<HostsRecord[]>([]);
async function fetchHosts() {
const records = await pb.collection('hosts').getFullList();
hosts.set(records);
}
async function createHost(host: {
ip: string;
mac: string;
name: string;
port: number;
user: RecordIdString;
}) {
await pb.collection('hosts').create(host);
fetchHosts();
}
async function updateHost(host: HostsRecord) {
await pb.collection('hosts').update(host.id, host);
fetchHosts();
}
async function deleteHost(host: HostsRecord) {
await pb.collection('hosts').delete(host.id);
fetchHosts();
}
async function wakeHost(host: HostsRecord) {
pb.send('/api/wake', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ id: host.id })
})
.then((res) => {
toast.success('WakeOnLan Magic Packet Sent');
})
.catch((err) => {
toast.error('Failed to wake host', {
description: err.message
});
});
}
return {
...hosts,
fetchHosts,
createHost,
updateHost,
deleteHost,
wakeHost
};
}
export const hostsStore = createHostsStore();

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import Navbar from '$lib/components/navbar.svelte';
import '../app.css';
import { ModeWatcher } from 'mode-watcher';
import { Toaster } from 'svelte-sonner';
let { children } = $props();
</script>
<ModeWatcher defaultMode="dark" />
<Toaster richColors position="top-right" />
<div class="min-h-screen bg-gradient-to-br from-background via-background to-muted/20">
<!-- Background pattern -->
<div class="fixed inset-0 bg-[radial-gradient(circle_at_50%_120%,rgba(120,119,198,0.05),transparent)] pointer-events-none"></div>
<Navbar />
<div class="relative">
{@render children?.()}
</div>
<footer class="fixed bottom-0 w-full px-4 py-3 backdrop-blur-md bg-background/30 border-t border-border/50">
<div class="max-w-7xl mx-auto flex items-center justify-between text-xs text-muted-foreground">
<span>Wake-on-LAN Manager</span>
<span>Made with ❤️ by <a target="_blank" href="https://github.com/0x1eo" class="text-primary hover:underline">Leandro</a></span>
</div>
</footer>
</div>

View File

@@ -0,0 +1,2 @@
export let prerender = true;
export let ssr = false;

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import { goto } from '$app/navigation';
import CreateHostForm from '$lib/components/CreateHostForm.svelte';
import HostCard from '$lib/components/HostCard.svelte';
import { pb } from '$lib/pb';
import { hostsStore } from '$lib/stores/hosts';
import autoAnimate from '@formkit/auto-animate';
import { Server } from 'lucide-svelte';
$effect(() => {
if (pb.authStore.isValid) {
hostsStore.fetchHosts();
} else {
goto('/auth');
}
});
</script>
<main class="pt-24 pb-20 px-4 sm:px-6 lg:px-8">
<div class="max-w-5xl mx-auto space-y-8">
<!-- Header -->
<div class="text-center space-y-2">
<h1 class="text-4xl font-bold tracking-tight bg-gradient-to-r from-foreground to-foreground/70 bg-clip-text text-transparent">
Your Devices
</h1>
<p class="text-muted-foreground">Manage and wake your network devices remotely</p>
</div>
<!-- Create Host Form -->
<div class="glass-effect rounded-xl p-6 border shadow-lg">
<div class="flex items-center space-x-2 mb-4">
<div class="w-8 h-8 rounded-lg bg-primary/10 flex items-center justify-center">
<Server class="h-4 w-4 text-primary" />
</div>
<h2 class="text-xl font-semibold">Add New Device</h2>
</div>
<CreateHostForm />
</div>
<!-- Hosts List -->
{#if $hostsStore.length > 0}
<div class="space-y-4">
<h2 class="text-2xl font-semibold flex items-center">
<span class="mr-2">Devices</span>
<span class="text-sm font-normal text-muted-foreground">({$hostsStore.length})</span>
</h2>
<ul use:autoAnimate class="grid grid-cols-1 lg:grid-cols-2 gap-4">
{#each $hostsStore as host (host.id)}
<HostCard {host} />
{/each}
</ul>
</div>
{:else}
<div class="glass-effect rounded-xl p-12 text-center border">
<Server class="h-16 w-16 mx-auto text-muted-foreground/50 mb-4" />
<h3 class="text-xl font-semibold mb-2">No devices yet</h3>
<p class="text-muted-foreground">Add your first device above to get started</p>
</div>
{/if}
</div>
</main>

View File

@@ -0,0 +1,130 @@
<script lang="ts">
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Info, Lock, Mail, Zap, ShieldCheck } from 'lucide-svelte';
import { pb } from '$lib/pb';
import { toast } from 'svelte-sonner';
import * as Popover from '$lib/components/ui/popover/index.js';
import { goto } from '$app/navigation';
import { dev } from '$app/environment';
import { page } from '$app/state';
let email = $state('');
let password = $state('');
$effect(() => {
if (pb.authStore.isValid) {
toast.warning('Already logged in');
goto('/');
}
});
function login(e: Event) {
e.preventDefault();
pb.collection('users')
.authWithPassword(email, password)
.then((authData) => {
toast.success('Logged in', { description: authData.record.name });
goto('/');
})
.catch((err) => {
toast.error(err.message);
});
}
</script>
<div class="flex min-h-screen items-center justify-center px-4 pt-16 pb-20">
<div class="w-full max-w-md space-y-8">
<!-- Hero Section -->
<div class="text-center space-y-4">
<div class="flex items-center justify-center mb-6">
<div class="flex items-center justify-center w-16 h-16 rounded-2xl bg-primary/10 glow-primary">
<ShieldCheck class="h-8 w-8 text-primary" />
</div>
</div>
<h1 class="text-3xl font-bold tracking-tight">Welcome Back</h1>
<p class="text-muted-foreground">Sign in to manage your Wake-on-LAN devices</p>
</div>
<!-- Login Card -->
<Card.Root class="glass-effect border shadow-2xl">
<form onsubmit={login}>
<Card.Header class="space-y-1">
<Card.Title class="text-2xl">Sign In</Card.Title>
<Card.Description class="flex items-center justify-between">
<span>Enter your credentials to continue</span>
<Popover.Root>
<Popover.Trigger class={buttonVariants({ variant: 'ghost', size: 'icon', class: 'h-8 w-8' })}>
<Info class="h-4 w-4" />
</Popover.Trigger>
<Popover.Content class="w-80 glass-effect">
{@const pbAdminUrl = dev
? 'http://localhost:8090'
: page.url.protocol + '//' + page.url.host + '/_/'}
<div class="space-y-2">
<h4 class="font-semibold text-sm flex items-center">
<Info class="h-4 w-4 mr-2" />
Account Information
</h4>
<p class="text-sm text-muted-foreground">
Accounts can only be created by an administrator from the admin panel.
</p>
<a
target="_blank"
href={pbAdminUrl}
class="text-sm text-primary hover:underline flex items-center"
>
Open Admin Panel →
</a>
</div>
</Popover.Content>
</Popover.Root>
</Card.Description>
</Card.Header>
<Card.Content class="space-y-4">
<div class="space-y-2">
<Label for="email" class="text-sm font-medium">Email Address</Label>
<div class="relative">
<Mail class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="email"
type="email"
placeholder="you@example.com"
bind:value={email}
autofocus
class="pl-10 h-11"
/>
</div>
</div>
<div class="space-y-2">
<Label for="password" class="text-sm font-medium">Password</Label>
<div class="relative">
<Lock class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
id="password"
type="password"
placeholder="••••••••"
bind:value={password}
class="pl-10 h-11"
/>
</div>
</div>
</Card.Content>
<Card.Footer class="flex flex-col space-y-4">
<Button
type="submit"
class="w-full h-11 bg-primary hover:bg-primary/90 font-semibold"
>
<Zap class="h-4 w-4 mr-2" />
Sign In
</Button>
</Card.Footer>
</form>
</Card.Root>
</div>
</div>

BIN
apps/web/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

18
apps/web/svelte.config.js Normal file
View File

@@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

View File

@@ -0,0 +1,96 @@
import { fontFamily } from "tailwindcss/defaultTheme";
import type { Config } from "tailwindcss";
import tailwindcssAnimate from "tailwindcss-animate";
const config: Config = {
darkMode: ["class"],
content: ["./src/**/*.{html,js,svelte,ts}"],
safelist: ["dark"],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px"
}
},
extend: {
colors: {
border: "hsl(var(--border) / <alpha-value>)",
input: "hsl(var(--input) / <alpha-value>)",
ring: "hsl(var(--ring) / <alpha-value>)",
background: "hsl(var(--background) / <alpha-value>)",
foreground: "hsl(var(--foreground) / <alpha-value>)",
primary: {
DEFAULT: "hsl(var(--primary) / <alpha-value>)",
foreground: "hsl(var(--primary-foreground) / <alpha-value>)"
},
secondary: {
DEFAULT: "hsl(var(--secondary) / <alpha-value>)",
foreground: "hsl(var(--secondary-foreground) / <alpha-value>)"
},
destructive: {
DEFAULT: "hsl(var(--destructive) / <alpha-value>)",
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)"
},
muted: {
DEFAULT: "hsl(var(--muted) / <alpha-value>)",
foreground: "hsl(var(--muted-foreground) / <alpha-value>)"
},
accent: {
DEFAULT: "hsl(var(--accent) / <alpha-value>)",
foreground: "hsl(var(--accent-foreground) / <alpha-value>)"
},
popover: {
DEFAULT: "hsl(var(--popover) / <alpha-value>)",
foreground: "hsl(var(--popover-foreground) / <alpha-value>)"
},
card: {
DEFAULT: "hsl(var(--card) / <alpha-value>)",
foreground: "hsl(var(--card-foreground) / <alpha-value>)"
},
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: {
xl: "calc(var(--radius) + 4px)",
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)"
},
fontFamily: {
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],
};
export default config;

19
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// 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
// from the referenced tsconfig.json - TypeScript does not merge them in
}

10
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
import { sveltekit } from '@sveltejs/kit/vite';
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
}
});