first commit
This commit is contained in:
24
apps/web/.gitignore
vendored
Normal file
24
apps/web/.gitignore
vendored
Normal 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
1
apps/web/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
engine-strict=true
|
||||
4
apps/web/.prettierignore
Normal file
4
apps/web/.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
15
apps/web/.prettierrc
Normal file
15
apps/web/.prettierrc
Normal 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
38
apps/web/README.md
Normal 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
17
apps/web/components.json
Normal 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"
|
||||
}
|
||||
6
apps/web/e2e/demo.test.ts
Normal file
6
apps/web/e2e/demo.test.ts
Normal 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
51
apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
10
apps/web/playwright.config.ts
Normal file
10
apps/web/playwright.config.ts
Normal 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
6
apps/web/postbuild.ts
Normal 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 });
|
||||
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
89
apps/web/src/app.css
Normal file
89
apps/web/src/app.css
Normal 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
13
apps/web/src/app.d.ts
vendored
Normal 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
12
apps/web/src/app.html
Normal 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>
|
||||
7
apps/web/src/demo.spec.ts
Normal file
7
apps/web/src/demo.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
155
apps/web/src/lib/components/CreateHostForm.svelte
Normal file
155
apps/web/src/lib/components/CreateHostForm.svelte
Normal 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>
|
||||
85
apps/web/src/lib/components/HostCard.svelte
Normal file
85
apps/web/src/lib/components/HostCard.svelte
Normal 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>
|
||||
84
apps/web/src/lib/components/navbar.svelte
Normal file
84
apps/web/src/lib/components/navbar.svelte
Normal 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>
|
||||
74
apps/web/src/lib/components/ui/button/button.svelte
Normal file
74
apps/web/src/lib/components/ui/button/button.svelte
Normal 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}
|
||||
17
apps/web/src/lib/components/ui/button/index.ts
Normal file
17
apps/web/src/lib/components/ui/button/index.ts
Normal 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,
|
||||
};
|
||||
16
apps/web/src/lib/components/ui/card/card-content.svelte
Normal file
16
apps/web/src/lib/components/ui/card/card-content.svelte
Normal 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>
|
||||
16
apps/web/src/lib/components/ui/card/card-description.svelte
Normal file
16
apps/web/src/lib/components/ui/card/card-description.svelte
Normal 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>
|
||||
16
apps/web/src/lib/components/ui/card/card-footer.svelte
Normal file
16
apps/web/src/lib/components/ui/card/card-footer.svelte
Normal 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>
|
||||
16
apps/web/src/lib/components/ui/card/card-header.svelte
Normal file
16
apps/web/src/lib/components/ui/card/card-header.svelte
Normal 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>
|
||||
25
apps/web/src/lib/components/ui/card/card-title.svelte
Normal file
25
apps/web/src/lib/components/ui/card/card-title.svelte
Normal 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>
|
||||
20
apps/web/src/lib/components/ui/card/card.svelte
Normal file
20
apps/web/src/lib/components/ui/card/card.svelte
Normal 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>
|
||||
22
apps/web/src/lib/components/ui/card/index.ts
Normal file
22
apps/web/src/lib/components/ui/card/index.ts
Normal 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,
|
||||
};
|
||||
7
apps/web/src/lib/components/ui/form/form-button.svelte
Normal file
7
apps/web/src/lib/components/ui/form/form-button.svelte
Normal 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} />
|
||||
17
apps/web/src/lib/components/ui/form/form-description.svelte
Normal file
17
apps/web/src/lib/components/ui/form/form-description.svelte
Normal 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}
|
||||
/>
|
||||
@@ -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>
|
||||
31
apps/web/src/lib/components/ui/form/form-field-errors.svelte
Normal file
31
apps/web/src/lib/components/ui/form/form-field-errors.svelte
Normal 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>
|
||||
30
apps/web/src/lib/components/ui/form/form-field.svelte
Normal file
30
apps/web/src/lib/components/ui/form/form-field.svelte
Normal 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>
|
||||
21
apps/web/src/lib/components/ui/form/form-fieldset.svelte
Normal file
21
apps/web/src/lib/components/ui/form/form-fieldset.svelte
Normal 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} />
|
||||
21
apps/web/src/lib/components/ui/form/form-label.svelte
Normal file
21
apps/web/src/lib/components/ui/form/form-label.svelte
Normal 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>
|
||||
17
apps/web/src/lib/components/ui/form/form-legend.svelte
Normal file
17
apps/web/src/lib/components/ui/form/form-legend.svelte
Normal 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}
|
||||
/>
|
||||
33
apps/web/src/lib/components/ui/form/index.ts
Normal file
33
apps/web/src/lib/components/ui/form/index.ts
Normal 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,
|
||||
};
|
||||
7
apps/web/src/lib/components/ui/input/index.ts
Normal file
7
apps/web/src/lib/components/ui/input/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./input.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
||||
22
apps/web/src/lib/components/ui/input/input.svelte
Normal file
22
apps/web/src/lib/components/ui/input/input.svelte
Normal 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}
|
||||
/>
|
||||
7
apps/web/src/lib/components/ui/label/index.ts
Normal file
7
apps/web/src/lib/components/ui/label/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
};
|
||||
19
apps/web/src/lib/components/ui/label/label.svelte
Normal file
19
apps/web/src/lib/components/ui/label/label.svelte
Normal 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}
|
||||
/>
|
||||
17
apps/web/src/lib/components/ui/popover/index.ts
Normal file
17
apps/web/src/lib/components/ui/popover/index.ts
Normal 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,
|
||||
};
|
||||
@@ -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>
|
||||
7
apps/web/src/lib/components/ui/separator/index.ts
Normal file
7
apps/web/src/lib/components/ui/separator/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from "./separator.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Separator,
|
||||
};
|
||||
22
apps/web/src/lib/components/ui/separator/separator.svelte
Normal file
22
apps/web/src/lib/components/ui/separator/separator.svelte
Normal 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}
|
||||
/>
|
||||
1
apps/web/src/lib/components/ui/sonner/index.ts
Normal file
1
apps/web/src/lib/components/ui/sonner/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Toaster } from "./sonner.svelte";
|
||||
20
apps/web/src/lib/components/ui/sonner/sonner.svelte
Normal file
20
apps/web/src/lib/components/ui/sonner/sonner.svelte
Normal 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}
|
||||
/>
|
||||
1
apps/web/src/lib/index.ts
Normal file
1
apps/web/src/lib/index.ts
Normal 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
5
apps/web/src/lib/pb.ts
Normal 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;
|
||||
155
apps/web/src/lib/pocketbase-types.ts
Normal file
155
apps/web/src/lib/pocketbase-types.ts
Normal 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>
|
||||
}
|
||||
64
apps/web/src/lib/stores/hosts.ts
Normal file
64
apps/web/src/lib/stores/hosts.ts
Normal 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();
|
||||
6
apps/web/src/lib/utils.ts
Normal file
6
apps/web/src/lib/utils.ts
Normal 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));
|
||||
}
|
||||
28
apps/web/src/routes/+layout.svelte
Normal file
28
apps/web/src/routes/+layout.svelte
Normal 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>
|
||||
2
apps/web/src/routes/+layout.ts
Normal file
2
apps/web/src/routes/+layout.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export let prerender = true;
|
||||
export let ssr = false;
|
||||
61
apps/web/src/routes/+page.svelte
Normal file
61
apps/web/src/routes/+page.svelte
Normal 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>
|
||||
130
apps/web/src/routes/auth/+page.svelte
Normal file
130
apps/web/src/routes/auth/+page.svelte
Normal 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
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
18
apps/web/svelte.config.js
Normal 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;
|
||||
96
apps/web/tailwind.config.ts
Normal file
96
apps/web/tailwind.config.ts
Normal 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
19
apps/web/tsconfig.json
Normal 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
10
apps/web/vite.config.ts
Normal 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}']
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user