feat: basic song ui + settings

This commit is contained in:
2024-11-24 00:11:36 +01:00
parent 2c9051a265
commit 7ae47d02f2
99 changed files with 3680 additions and 64 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,33 +0,0 @@
import prettier from 'eslint-config-prettier';
import js from '@eslint/js';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import ts from 'typescript-eslint';
export default ts.config(
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
...svelte.configs['flat/prettier'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.svelte'],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
},
{
ignores: ['build/', '.svelte-kit/', 'dist/']
}
);

View File

@@ -8,19 +8,17 @@
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .", "format": "prettier --write ."
"lint": "prettier --check . && eslint ."
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"bits-ui": "^1.0.0-next.64",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"eslint": "^9.7.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0",
"globals": "^15.0.0", "globals": "^15.0.0",
"lucide-svelte": "^0.460.1",
"prettier": "^3.3.2", "prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.6", "prettier-plugin-svelte": "^3.2.6",
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.6.5",
@@ -31,7 +29,12 @@
"tailwindcss": "^3.4.9", "tailwindcss": "^3.4.9",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"typescript-eslint": "^8.0.0",
"vite": "^5.0.3" "vite": "^5.0.3"
},
"dependencies": {
"@protobuf-ts/grpcweb-transport": "^2.9.4",
"@protobuf-ts/plugin": "^2.9.4",
"dayjs": "^1.11.13",
"mode-watcher": "^0.5.0"
} }
} }

20
protos/library.proto Normal file
View File

@@ -0,0 +1,20 @@
syntax = "proto3";
import 'google/protobuf/empty.proto';
package library;
service Library {
rpc ListTracks(google.protobuf.Empty) returns (TrackList);
}
message TrackList {
repeated Track tracks = 1;
}
message Track {
string hash = 1;
string name = 2;
string artist_name = 3;
uint64 artist_id = 4;
}

18
protos/player.proto Normal file
View File

@@ -0,0 +1,18 @@
syntax = "proto3";
import 'google/protobuf/empty.proto';
package player;
service Player {
rpc PlayTrack(PlayTrackRequest) returns (PlayTrackResponse);
rpc ResumeTrack(google.protobuf.Empty) returns (google.protobuf.Empty);
rpc PauseTrack(google.protobuf.Empty) returns (google.protobuf.Empty);
}
message PlayTrackRequest {
string hash = 1;
}
message PlayTrackResponse {
}

43
protos/settings.proto Normal file
View File

@@ -0,0 +1,43 @@
syntax = "proto3";
import 'google/protobuf/empty.proto';
package settings;
service Settings {
rpc ListPaths(google.protobuf.Empty) returns (SettingsData);
rpc AddPath(AddPathRequest) returns (AddPathResponse);
rpc DeletePath(DeletePathRequest) returns (DeletePathResponse);
rpc RefreshPath(RefreshPathRequest) returns (RefreshPathResponse);
}
message SettingsData {
repeated LibraryPath library_paths = 1;
}
message LibraryPath {
uint64 id = 1;
string path = 2;
}
message AddPathRequest {
string path = 1;
}
message AddPathResponse {
uint64 id = 1;
}
message DeletePathRequest {
uint64 id = 1;
}
message DeletePathResponse {
}
message RefreshPathRequest {
uint64 id = 1;
}
message RefreshPathResponse {
}

View File

@@ -6,24 +6,24 @@
:root { :root {
--background: 0 0% 100%; --background: 0 0% 100%;
--foreground: 222.2 84% 4.9%; --foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%; --card-foreground: 222.2 84% 4.9%;
--border: 214.3 31.8% 91.4%; --popover: 0 0% 100%;
--input: 214.3 31.8% 91.4%; --popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%; --primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%; --primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%; --secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%; --secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%; --accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%; --accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 72.2% 50.6%; --destructive: 0 100% 67%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 0 100% 5%;
--ring: 222.2 84% 4.9%; --border: 214.3 31.8% 91.4%;
--radius: 0.5rem; --input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--sidebar-background: 0 0% 98%; --sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%; --sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%; --sidebar-primary: 240 5.9% 10%;
@@ -32,28 +32,30 @@
--sidebar-accent-foreground: 240 5.9% 10%; --sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%; --sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%; --sidebar-ring: 217.2 91.2% 59.8%;
}
--radius: 0.5rem;
}
.dark { .dark {
--background: 222.2 84% 4.9%; --background: 222.2 84% 4.9%;
--foreground: 210 40% 98%; --foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--card: 222.2 84% 4.9%; --card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%; --card-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%; --popover: 222.2 84% 4.9%;
--input: 217.2 32.6% 17.5%; --popover-foreground: 210 40% 98%;
--primary: 210 40% 98%; --primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%; --primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%; --secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%; --secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%; --accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%; --accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%; --destructive: 0 100% 67%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 0 100% 5%;
--ring: 212.7 26.8% 83.9%; --border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
--sidebar-background: 240 5.9% 10%; --sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%; --sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%; --sidebar-primary: 224.3 76.3% 48%;
@@ -73,3 +75,7 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
* {
-webkit-user-drag: none !important;
}

5
src/hooks.server.ts Normal file
View File

@@ -0,0 +1,5 @@
import { GrpcWebFetchTransport } from '@protobuf-ts/grpcweb-transport';
export const protoTransport = new GrpcWebFetchTransport({
baseUrl: 'http://[::1]:39993'
});

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import { page } from '$app/stores';
import * as Sidebar from '$lib/components/ui/sidebar';
import { Home, Library, ListMusic, Music2, Settings } from 'lucide-svelte';
</script>
<Sidebar.Root collapsible="icon">
<Sidebar.Header>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={$page.url.pathname === '/'} class="transition-all">
{#snippet child({ props })}
<a href="/" {...props}>
<Home />
<span>Home</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.GroupLabel>Library</Sidebar.GroupLabel>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={$page.url.pathname === '/songs'} class="transition-all">
{#snippet child({ props })}
<a href="/songs" {...props}>
<Music2 />
<span>Songs</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={$page.url.pathname === '/albums'} class="transition-all">
{#snippet child({ props })}
<a href="/albums" {...props}>
<Library />
<span>Albums</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={$page.url.pathname === '/playlists'} class="transition-all">
{#snippet child({ props })}
<a href="/playlists" {...props}>
<ListMusic />
<span>Playlists</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer>
<Sidebar.Menu>
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={$page.url.pathname === '/settings'} class="transition-all">
{#snippet child({ props })}
<a href="/settings" {...props}>
<Settings />
<span>Settings</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Footer>
</Sidebar.Root>

View File

@@ -0,0 +1,160 @@
<script lang="ts">
import {
Play,
Pause,
SkipBack,
SkipForward,
VolumeX,
Volume1,
Volume2,
List
} from 'lucide-svelte';
import { Button } from '$lib/components/ui/button';
import { Progress } from '$lib/components/ui/progress';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { Slider } from '$lib/components/ui/slider';
import * as Popover from '$lib/components/ui/popover';
import { Separator } from '$lib/components/ui/separator';
import { currentlyPlaying, volume } from '$lib/stores/player';
import { enhance } from '$app/forms';
dayjs.extend(duration);
let progressValue = $state(0);
let isPlaying = $state(false);
/* async function seekProgressValue(e: MouseEvent) {
if (!$currentlyPlaying) {
return;
}
const target = e.currentTarget! as HTMLElement;
const _targetProgress = Math.max(0.0, Math.min(1.0, e.offsetX / target.offsetWidth));
} */
async function skipToQueueIndex(index: number) {
console.log(`SKIP TO QUEUE INDEX ${index}`);
}
let volumeValue = {
get value() {
return [$volume];
},
set value(v: number[]) {
$volume = v[0];
// TODO: Send message to player
}
};
</script>
<footer class="border border-x-0 border-t border-border/40 bg-background/95 px-4">
<nav class="my-2 grid w-full grid-cols-5 items-center">
<div class="col-span-1 flex items-end gap-2">
{#if $currentlyPlaying}
<img
class="w-16 rounded-md shadow-2xl shadow-primary/50"
src="https://i.scdn.co/image/ab67616d0000b2732c0ead8ce0dd1c6e2fca817f"
alt={$currentlyPlaying}
/>
<div class="space-y-1 text-sm">
<h3 class="font-medium leading-none">Song name</h3>
<p class="text-xs text-muted-foreground">Artist name</p>
</div>
{:else}
<div class="h-16 w-16 rounded-md bg-muted shadow-2xl shadow-primary/50"></div>
{/if}
</div>
<form class="col-span-3 flex justify-center gap-1" method="POST" use:enhance>
<Button variant="outline" size="icon">
<SkipBack />
</Button>
<Button type="submit" formaction="/player?/pause" variant="outline" size="icon">
{#if isPlaying}
<Pause />
{:else}
<Play />
{/if}
</Button>
<Button variant="outline" size="icon">
<SkipForward />
</Button>
</form>
<div class="col-span-1 flex items-center">
<Popover.Root>
<Popover.Trigger class="mr-8 flex items-center text-sm">
{#snippet child({ props })}
<Button {...props} variant="ghost" size="sm">
<List class="mr-2 size-4" />
<span>15</span>
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="flex max-h-[max(50vh,300px)] w-64 flex-col overflow-y-hidden">
<p class="font-semibold">Next up</p>
<Separator class="my-1" />
<div class="flex h-full flex-col gap-1 overflow-y-auto pr-3">
{#each [] as _song, i}
<button
class="flex flex-row items-center gap-2 rounded-lg px-3 py-2 transition-all hover:bg-secondary"
onclick={() => skipToQueueIndex(i)}
>
<div class="min-w-8 overflow-hidden rounded-md">
<img
src="https://i.scdn.co/image/ab67616d0000b2732c0ead8ce0dd1c6e2fca817f"
class="aspect-square size-8"
alt="Cover"
/>
</div>
<div class="flex flex-col overflow-hidden">
<p
class="w-full self-start overflow-hidden text-ellipsis text-nowrap text-left text-sm text-foreground/80"
>
Song name
</p>
<p
class="w-full self-start overflow-hidden text-left text-xs text-muted-foreground"
>
Song artist
</p>
</div>
</button>
{/each}
</div>
</Popover.Content>
</Popover.Root>
<div class="flex grow flex-row items-center gap-4">
<div class="min-w-6 text-muted-foreground">
{#if $volume <= 0}
<VolumeX class="size-full" />
{:else if $volume < 50.0}
<Volume1 class="size-full" />
{:else}
<Volume2 class="size-full" />
{/if}
</div>
<div class="flex-1">
<Slider class="w-full" min={0} max={1} step={0.01} bind:value={volumeValue.value} />
</div>
<div class="min-w-10 self-center">
<p class="text-sm text-muted-foreground">
{Math.round($volume * 100.0)}%
</p>
</div>
</div>
</div>
</nav>
<div class="mb-2 flex flex-row items-center gap-2">
<span class="w-12 text-left text-sm">{dayjs.duration(0, 'seconds').format('mm:ss')}</span>
<button class="w-full">
{#key progressValue}
<Progress class="pointer-events-none w-full" value={progressValue} max={100} />
{/key}
</button>
<span class="w-12 text-right text-sm">{dayjs.duration(0, 'seconds').format('mm:ss')}</span>
</div>
</footer>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { getCoverUrl } from '$lib/covers';
// import { AudioLines } from 'lucide-svelte';
import type { Song } from '$lib/song';
interface Props {
song: Song;
}
let { song }: Props = $props();
async function playSong(e: MouseEvent) {
e.preventDefault();
// TODO: Play song
}
</script>
<form class="flex flex-col gap-3" method="POST" action="/songs/{song.hash}?/play" use:enhance>
<div class="relative">
<button type="submit" class="relative overflow-hidden rounded-lg" oncontextmenu={playSong}>
<img
class="aspect-square w-auto transition-all duration-150 hover:scale-105 hover:saturate-150"
src={getCoverUrl(song.hash)}
alt={song.name}
/>
</button>
<!-- <div
class="absolute bottom-6 left-2 size-4 animate-pulse"
class:hidden={$currentlyPlaying?.entry.hash !== song.entry.hash}
>
<AudioLines class="text-primary" />
</div> -->
</div>
<div class="relative space-y-1 text-sm">
<h3 class="font-medium leading-none">{song.name}</h3>
<p class="text-xs text-muted-foreground">{song.artistName}</p>
</div>
</form>

View File

@@ -0,0 +1,13 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Moon, Sun } from 'lucide-svelte';
import { toggleMode, mode } from 'mode-watcher';
</script>
<Button variant="outline" size="icon" onclick={toggleMode}>
{#if $mode === 'light'}
<Sun />
{:else}
<Moon />
{/if}
</Button>

View File

@@ -0,0 +1,69 @@
<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: '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-1 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 shadow',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm',
outline:
'border-input bg-background hover:bg-accent hover:text-accent-foreground border shadow-sm',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-sm',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9'
}
},
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,40 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import Check from 'lucide-svelte/icons/check';
import Minus from 'lucide-svelte/icons/minus';
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
checked = $bindable(false),
indeterminate = $bindable(false),
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<ContextMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<ContextMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
class={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50',
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span class="absolute left-2 flex size-3.5 items-center justify-center">
{#if indeterminate}
<Minus class="size-4" />
{:else}
<Check class={cn('size-4', !checked && 'text-transparent')} />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</ContextMenuPrimitive.CheckboxItem>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
portalProps,
...restProps
}: ContextMenuPrimitive.ContentProps & {
portalProps?: ContextMenuPrimitive.PortalProps;
} = $props();
</script>
<ContextMenuPrimitive.Portal {...portalProps}>
<ContextMenuPrimitive.Content
class={cn(
'z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md focus:outline-none',
className
)}
{...restProps}
bind:ref
/>
</ContextMenuPrimitive.Portal>

View File

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

View File

@@ -0,0 +1,23 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ContextMenuPrimitive.ItemProps & {
inset?: boolean;
} = $props();
</script>
<ContextMenuPrimitive.Item
class={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
bind:ref
{...restProps}
/>

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive, type WithoutChild } from 'bits-ui';
import Circle from 'lucide-svelte/icons/circle';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children: childrenProp,
...restProps
}: WithoutChild<ContextMenuPrimitive.RadioItemProps> = $props();
</script>
<ContextMenuPrimitive.RadioItem
bind:ref
class={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50',
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span class="absolute left-2 flex size-3.5 items-center justify-center">
{#if checked}
<Circle class="size-2 fill-current" />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</ContextMenuPrimitive.RadioItem>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: ContextMenuPrimitive.SeparatorProps = $props();
</script>
<ContextMenuPrimitive.Separator
bind:ref
class={cn('-mx-1 my-1 h-px bg-border', className)}
{...restProps}
/>

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<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
class={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
{...restProps}
>
{@render children?.()}
</span>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: ContextMenuPrimitive.SubContentProps = $props();
</script>
<ContextMenuPrimitive.SubContent
bind:ref
class={cn(
'z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-lg focus:outline-none',
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import { ContextMenu as ContextMenuPrimitive, type WithoutChild } from 'bits-ui';
import ChevronRight from 'lucide-svelte/icons/chevron-right';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
inset,
...restProps
}: WithoutChild<ContextMenuPrimitive.SubTriggerProps> & {
inset?: boolean;
} = $props();
</script>
<ContextMenuPrimitive.SubTrigger
bind:ref
class={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[state=open]:bg-accent data-[highlighted]:text-accent-foreground data-[state=open]:text-accent-foreground',
inset && 'pl-8',
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRight class="ml-auto size-4" />
</ContextMenuPrimitive.SubTrigger>

View File

@@ -0,0 +1,49 @@
import { ContextMenu as ContextMenuPrimitive } from 'bits-ui';
import Item from './context-menu-item.svelte';
import GroupHeading from './context-menu-group-heading.svelte';
import Content from './context-menu-content.svelte';
import Shortcut from './context-menu-shortcut.svelte';
import RadioItem from './context-menu-radio-item.svelte';
import Separator from './context-menu-separator.svelte';
import SubContent from './context-menu-sub-content.svelte';
import SubTrigger from './context-menu-sub-trigger.svelte';
import CheckboxItem from './context-menu-checkbox-item.svelte';
const Sub: typeof ContextMenuPrimitive.Sub = ContextMenuPrimitive.Sub;
const Root: typeof ContextMenuPrimitive.Root = ContextMenuPrimitive.Root;
const Trigger: typeof ContextMenuPrimitive.Trigger = ContextMenuPrimitive.Trigger;
const Group: typeof ContextMenuPrimitive.Group = ContextMenuPrimitive.Group;
const RadioGroup: typeof ContextMenuPrimitive.RadioGroup = ContextMenuPrimitive.RadioGroup;
export {
Sub,
Root,
Item,
Group,
Trigger,
Content,
Shortcut,
Separator,
RadioItem,
GroupHeading,
SubContent,
SubTrigger,
RadioGroup,
CheckboxItem,
//
Root as ContextMenu,
Sub as ContextMenuSub,
Item as ContextMenuItem,
Group as ContextMenuGroup,
Content as ContextMenuContent,
Trigger as ContextMenuTrigger,
Shortcut as ContextMenuShortcut,
RadioItem as ContextMenuRadioItem,
Separator as ContextMenuSeparator,
GroupHeading as ContextMenuGroupHeading,
RadioGroup as ContextMenuRadioGroup,
SubContent as ContextMenuSubContent,
SubTrigger as ContextMenuSubTrigger,
CheckboxItem as ContextMenuCheckboxItem
};

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(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
className
)}
bind:value
{...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,
align = 'center',
sideOffset = 4,
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: PopoverPrimitive.PortalProps;
} = $props();
</script>
<PopoverPrimitive.Portal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
{align}
{sideOffset}
class={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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',
className
)}
{...restProps}
/>
</PopoverPrimitive.Portal>

View File

@@ -0,0 +1,7 @@
import Root from './progress.svelte';
export {
Root,
//
Root as Progress
};

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { Progress as ProgressPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
max = 100,
value,
...restProps
}: WithoutChildrenOrChild<ProgressPrimitive.RootProps> = $props();
</script>
<ProgressPrimitive.Root
bind:ref
{value}
class={cn('relative h-2 w-full overflow-hidden rounded-full bg-primary/20', className)}
{...restProps}
>
<div
class="h-full w-full flex-1 bg-primary transition-all"
style={`transform: translateX(-${100 - (100 * (value ?? 0)) / (max ?? 1)}%)`}
></div>
</ProgressPrimitive.Root>

View File

@@ -0,0 +1,10 @@
import Scrollbar from './scroll-area-scrollbar.svelte';
import Root from './scroll-area.svelte';
export {
Root,
Scrollbar,
//,
Root as ScrollArea,
Scrollbar as ScrollAreaScrollbar
};

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import { ScrollArea as ScrollAreaPrimitive, type WithoutChild } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
orientation = 'vertical',
children,
...restProps
}: WithoutChild<ScrollAreaPrimitive.ScrollbarProps> = $props();
</script>
<ScrollAreaPrimitive.Scrollbar
bind:ref
{orientation}
class={cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-px',
orientation === 'horizontal' && 'h-2.5 w-full border-t border-t-transparent p-px',
className
)}
{...restProps}
>
{@render children?.()}
<ScrollAreaPrimitive.Thumb
class={cn('relative rounded-full bg-border', orientation === 'vertical' && 'flex-1')}
/>
</ScrollAreaPrimitive.Scrollbar>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { ScrollArea as ScrollAreaPrimitive, type WithoutChild } from 'bits-ui';
import { Scrollbar } from './index.js';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
orientation = 'vertical',
scrollbarXClasses = '',
scrollbarYClasses = '',
children,
...restProps
}: WithoutChild<ScrollAreaPrimitive.RootProps> & {
orientation?: 'vertical' | 'horizontal' | 'both' | undefined;
scrollbarXClasses?: string | undefined;
scrollbarYClasses?: string | undefined;
} = $props();
</script>
<ScrollAreaPrimitive.Root bind:ref {...restProps} class={cn('relative overflow-hidden', className)}>
<ScrollAreaPrimitive.Viewport class="h-full w-full rounded-[inherit]">
{@render children?.()}
</ScrollAreaPrimitive.Viewport>
{#if orientation === 'vertical' || orientation === 'both'}
<Scrollbar orientation="vertical" class={scrollbarYClasses} />
{/if}
{#if orientation === 'horizontal' || orientation === 'both'}
<Scrollbar orientation="horizontal" class={scrollbarXClasses} />
{/if}
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>

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(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'min-h-full w-[1px]',
className
)}
{orientation}
{...restProps}
/>

View File

@@ -0,0 +1,37 @@
import { Dialog as SheetPrimitive } from 'bits-ui';
import Overlay from './sheet-overlay.svelte';
import Content from './sheet-content.svelte';
import Header from './sheet-header.svelte';
import Footer from './sheet-footer.svelte';
import Title from './sheet-title.svelte';
import Description from './sheet-description.svelte';
const Root = SheetPrimitive.Root;
const Close = SheetPrimitive.Close;
const Trigger = SheetPrimitive.Trigger;
const Portal = SheetPrimitive.Portal;
export {
Root,
Close,
Trigger,
Portal,
Overlay,
Content,
Header,
Footer,
Title,
Description,
//
Root as Sheet,
Close as SheetClose,
Trigger as SheetTrigger,
Portal as SheetPortal,
Overlay as SheetOverlay,
Content as SheetContent,
Header as SheetHeader,
Footer as SheetFooter,
Title as SheetTitle,
Description as SheetDescription
};

View File

@@ -0,0 +1,56 @@
<script lang="ts" module>
import { tv, type VariantProps } from 'tailwind-variants';
export const sheetVariants = tv({
base: 'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 gap-4 p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
variants: {
side: {
top: 'data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 border-b',
bottom:
'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 border-t',
left: 'data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm',
right:
'data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm'
}
},
defaultVariants: {
side: 'right'
}
});
export type Side = VariantProps<typeof sheetVariants>['side'];
</script>
<script lang="ts">
import { Dialog as SheetPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import X from 'lucide-svelte/icons/x';
import type { Snippet } from 'svelte';
import SheetOverlay from './sheet-overlay.svelte';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
portalProps,
side = 'right',
children,
...restProps
}: WithoutChildrenOrChild<SheetPrimitive.ContentProps> & {
portalProps?: SheetPrimitive.PortalProps;
side?: Side;
children: Snippet;
} = $props();
</script>
<SheetPrimitive.Portal {...portalProps}>
<SheetOverlay />
<SheetPrimitive.Content bind:ref class={cn(sheetVariants({ side }), className)} {...restProps}>
{@render children?.()}
<SheetPrimitive.Close
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"
>
<X class="size-4" />
<span class="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPrimitive.Portal>

View File

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

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('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import type { WithElementRef } from 'bits-ui';
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-2 text-center sm:text-left', className)}
{...restProps}
>
{@render children?.()}
</div>

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.OverlayProps = $props();
</script>
<SheetPrimitive.Overlay
bind:ref
class={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...restProps}
/>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import { Dialog as SheetPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: SheetPrimitive.TitleProps = $props();
</script>
<SheetPrimitive.Title
bind:ref
class={cn('text-lg font-semibold text-foreground', className)}
{...restProps}
/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
import Root from './slider.svelte';
export {
Root,
//
Root as Slider
};

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { Slider as SliderPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
value = $bindable([0]),
...restProps
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props();
export { className as class };
</script>
<SliderPrimitive.Root
bind:ref
bind:value
class={cn('relative flex w-full touch-none select-none items-center', className)}
{...restProps}
>
{#snippet children({ thumbs })}
<span class="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range class="absolute h-full bg-primary" />
</span>
{#each thumbs as thumb}
<SliderPrimitive.Thumb
index={thumb}
class="block size-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
/>
{/each}
{/snippet}
</SliderPrimitive.Root>

View File

@@ -0,0 +1,18 @@
import { Tooltip as TooltipPrimitive } from 'bits-ui';
import Content from './tooltip-content.svelte';
const Root = TooltipPrimitive.Root;
const Trigger = TooltipPrimitive.Trigger;
const Provider = TooltipPrimitive.Provider;
export {
Root,
Trigger,
Content,
Provider,
//
Root as Tooltip,
Content as TooltipContent,
Trigger as TooltipTrigger,
Provider as TooltipProvider
};

View File

@@ -0,0 +1,21 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
...restProps
}: TooltipPrimitive.ContentProps = $props();
</script>
<TooltipPrimitive.Content
bind:ref
{sideOffset}
class={cn(
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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',
className
)}
{...restProps}
/>

3
src/lib/covers.ts Normal file
View File

@@ -0,0 +1,3 @@
export function getCoverUrl(songHash: string) {
return `http://localhost:39994/${songHash}.webp`;
}

View File

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

View File

@@ -0,0 +1,87 @@
// @generated by protobuf-ts 2.9.4
// @generated from protobuf file "google/protobuf/empty.proto" (package "google.protobuf", syntax proto3)
// tslint:disable
//
// Protocol Buffers - Google's data interchange format
// Copyright 2008 Google Inc. All rights reserved.
// https://developers.google.com/protocol-buffers/
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are
// met:
//
// * Redistributions of source code must retain the above copyright
// notice, this list of conditions and the following disclaimer.
// * Redistributions in binary form must reproduce the above
// copyright notice, this list of conditions and the following disclaimer
// in the documentation and/or other materials provided with the
// distribution.
// * Neither the name of Google Inc. nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
//
import type { BinaryWriteOptions } from '@protobuf-ts/runtime';
import type { IBinaryWriter } from '@protobuf-ts/runtime';
import { UnknownFieldHandler } from '@protobuf-ts/runtime';
import type { BinaryReadOptions } from '@protobuf-ts/runtime';
import type { IBinaryReader } from '@protobuf-ts/runtime';
import type { PartialMessage } from '@protobuf-ts/runtime';
import { reflectionMergePartial } from '@protobuf-ts/runtime';
import { MessageType } from '@protobuf-ts/runtime';
/**
* A generic empty message that you can re-use to avoid defining duplicated
* empty messages in your APIs. A typical example is to use it as the request
* or the response type of an API method. For instance:
*
* service Foo {
* rpc Bar(google.protobuf.Empty) returns (google.protobuf.Empty);
* }
*
*
* @generated from protobuf message google.protobuf.Empty
*/
export interface Empty {}
// @generated message type with reflection information, may provide speed optimized methods
class Empty$Type extends MessageType<Empty> {
constructor() {
super('google.protobuf.Empty', []);
}
create(value?: PartialMessage<Empty>): Empty {
const message = globalThis.Object.create(this.messagePrototype!);
if (value !== undefined) reflectionMergePartial<Empty>(this, message, value);
return message;
}
internalBinaryRead(
reader: IBinaryReader,
length: number,
options: BinaryReadOptions,
target?: Empty
): Empty {
return target ?? this.create();
}
internalBinaryWrite(
message: Empty,
writer: IBinaryWriter,
options: BinaryWriteOptions
): IBinaryWriter {
let u = options.writeUnknownFields;
if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message google.protobuf.Empty
*/
export const Empty = new Empty$Type();

View File

@@ -0,0 +1,37 @@
// @generated by protobuf-ts 2.9.4
// @generated from protobuf file "library.proto" (package "library", syntax proto3)
// tslint:disable
import type { RpcTransport } from '@protobuf-ts/runtime-rpc';
import type { ServiceInfo } from '@protobuf-ts/runtime-rpc';
import { Library } from './library';
import { stackIntercept } from '@protobuf-ts/runtime-rpc';
import type { TrackList } from './library';
import type { Empty } from './google/protobuf/empty';
import type { UnaryCall } from '@protobuf-ts/runtime-rpc';
import type { RpcOptions } from '@protobuf-ts/runtime-rpc';
/**
* @generated from protobuf service library.Library
*/
export interface ILibraryClient {
/**
* @generated from protobuf rpc: ListTracks(google.protobuf.Empty) returns (library.TrackList);
*/
listTracks(input: Empty, options?: RpcOptions): UnaryCall<Empty, TrackList>;
}
/**
* @generated from protobuf service library.Library
*/
export class LibraryClient implements ILibraryClient, ServiceInfo {
typeName = Library.typeName;
methods = Library.methods;
options = Library.options;
constructor(private readonly _transport: RpcTransport) {}
/**
* @generated from protobuf rpc: ListTracks(google.protobuf.Empty) returns (library.TrackList);
*/
listTracks(input: Empty, options?: RpcOptions): UnaryCall<Empty, TrackList> {
const method = this.methods[0],
opt = this._transport.mergeOptions(options);
return stackIntercept<Empty, TrackList>('unary', this._transport, method, opt, input);
}
}

207
src/lib/proto/library.ts Normal file
View File

@@ -0,0 +1,207 @@
// @generated by protobuf-ts 2.9.4
// @generated from protobuf file "library.proto" (package "library", syntax proto3)
// tslint:disable
import { Empty } from './google/protobuf/empty';
import { ServiceType } from '@protobuf-ts/runtime-rpc';
import type { BinaryWriteOptions } from '@protobuf-ts/runtime';
import type { IBinaryWriter } from '@protobuf-ts/runtime';
import { WireType } from '@protobuf-ts/runtime';
import type { BinaryReadOptions } from '@protobuf-ts/runtime';
import type { IBinaryReader } from '@protobuf-ts/runtime';
import { UnknownFieldHandler } from '@protobuf-ts/runtime';
import type { PartialMessage } from '@protobuf-ts/runtime';
import { reflectionMergePartial } from '@protobuf-ts/runtime';
import { MessageType } from '@protobuf-ts/runtime';
/**
* @generated from protobuf message library.TrackList
*/
export interface TrackList {
/**
* @generated from protobuf field: repeated library.Track tracks = 1;
*/
tracks: Track[];
}
/**
* @generated from protobuf message library.Track
*/
export interface Track {
/**
* @generated from protobuf field: string hash = 1;
*/
hash: string;
/**
* @generated from protobuf field: string name = 2;
*/
name: string;
/**
* @generated from protobuf field: string artist_name = 3;
*/
artistName: string;
/**
* @generated from protobuf field: uint64 artist_id = 4;
*/
artistId: bigint;
}
// @generated message type with reflection information, may provide speed optimized methods
class TrackList$Type extends MessageType<TrackList> {
constructor() {
super('library.TrackList', [
{ no: 1, name: 'tracks', kind: 'message', repeat: 1 /*RepeatType.PACKED*/, T: () => Track }
]);
}
create(value?: PartialMessage<TrackList>): TrackList {
const message = globalThis.Object.create(this.messagePrototype!);
message.tracks = [];
if (value !== undefined) reflectionMergePartial<TrackList>(this, message, value);
return message;
}
internalBinaryRead(
reader: IBinaryReader,
length: number,
options: BinaryReadOptions,
target?: TrackList
): TrackList {
let message = target ?? this.create(),
end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* repeated library.Track tracks */ 1:
message.tracks.push(Track.internalBinaryRead(reader, reader.uint32(), options));
break;
default:
let u = options.readUnknownField;
if (u === 'throw')
throw new globalThis.Error(
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(
this.typeName,
message,
fieldNo,
wireType,
d
);
}
}
return message;
}
internalBinaryWrite(
message: TrackList,
writer: IBinaryWriter,
options: BinaryWriteOptions
): IBinaryWriter {
/* repeated library.Track tracks = 1; */
for (let i = 0; i < message.tracks.length; i++)
Track.internalBinaryWrite(
message.tracks[i],
writer.tag(1, WireType.LengthDelimited).fork(),
options
).join();
let u = options.writeUnknownFields;
if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message library.TrackList
*/
export const TrackList = new TrackList$Type();
// @generated message type with reflection information, may provide speed optimized methods
class Track$Type extends MessageType<Track> {
constructor() {
super('library.Track', [
{ no: 1, name: 'hash', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
{ no: 2, name: 'name', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
{ no: 3, name: 'artist_name', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
{
no: 4,
name: 'artist_id',
kind: 'scalar',
T: 4 /*ScalarType.UINT64*/,
L: 0 /*LongType.BIGINT*/
}
]);
}
create(value?: PartialMessage<Track>): Track {
const message = globalThis.Object.create(this.messagePrototype!);
message.hash = '';
message.name = '';
message.artistName = '';
message.artistId = 0n;
if (value !== undefined) reflectionMergePartial<Track>(this, message, value);
return message;
}
internalBinaryRead(
reader: IBinaryReader,
length: number,
options: BinaryReadOptions,
target?: Track
): Track {
let message = target ?? this.create(),
end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* string hash */ 1:
message.hash = reader.string();
break;
case /* string name */ 2:
message.name = reader.string();
break;
case /* string artist_name */ 3:
message.artistName = reader.string();
break;
case /* uint64 artist_id */ 4:
message.artistId = reader.uint64().toBigInt();
break;
default:
let u = options.readUnknownField;
if (u === 'throw')
throw new globalThis.Error(
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(
this.typeName,
message,
fieldNo,
wireType,
d
);
}
}
return message;
}
internalBinaryWrite(
message: Track,
writer: IBinaryWriter,
options: BinaryWriteOptions
): IBinaryWriter {
/* string hash = 1; */
if (message.hash !== '') writer.tag(1, WireType.LengthDelimited).string(message.hash);
/* string name = 2; */
if (message.name !== '') writer.tag(2, WireType.LengthDelimited).string(message.name);
/* string artist_name = 3; */
if (message.artistName !== '')
writer.tag(3, WireType.LengthDelimited).string(message.artistName);
/* uint64 artist_id = 4; */
if (message.artistId !== 0n) writer.tag(4, WireType.Varint).uint64(message.artistId);
let u = options.writeUnknownFields;
if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message library.Track
*/
export const Track = new Track$Type();
/**
* @generated ServiceType for protobuf service library.Library
*/
export const Library = new ServiceType('library.Library', [
{ name: 'ListTracks', options: {}, I: Empty, O: TrackList }
]);

View File

@@ -0,0 +1,74 @@
// @generated by protobuf-ts 2.9.4
// @generated from protobuf file "player.proto" (package "player", syntax proto3)
// tslint:disable
import type { RpcTransport } from '@protobuf-ts/runtime-rpc';
import type { ServiceInfo } from '@protobuf-ts/runtime-rpc';
import { Player } from './player';
import type { Empty } from './google/protobuf/empty';
import { stackIntercept } from '@protobuf-ts/runtime-rpc';
import type { PlayTrackResponse } from './player';
import type { PlayTrackRequest } from './player';
import type { UnaryCall } from '@protobuf-ts/runtime-rpc';
import type { RpcOptions } from '@protobuf-ts/runtime-rpc';
/**
* @generated from protobuf service player.Player
*/
export interface IPlayerClient {
/**
* @generated from protobuf rpc: PlayTrack(player.PlayTrackRequest) returns (player.PlayTrackResponse);
*/
playTrack(
input: PlayTrackRequest,
options?: RpcOptions
): UnaryCall<PlayTrackRequest, PlayTrackResponse>;
/**
* @generated from protobuf rpc: ResumeTrack(google.protobuf.Empty) returns (google.protobuf.Empty);
*/
resumeTrack(input: Empty, options?: RpcOptions): UnaryCall<Empty, Empty>;
/**
* @generated from protobuf rpc: PauseTrack(google.protobuf.Empty) returns (google.protobuf.Empty);
*/
pauseTrack(input: Empty, options?: RpcOptions): UnaryCall<Empty, Empty>;
}
/**
* @generated from protobuf service player.Player
*/
export class PlayerClient implements IPlayerClient, ServiceInfo {
typeName = Player.typeName;
methods = Player.methods;
options = Player.options;
constructor(private readonly _transport: RpcTransport) {}
/**
* @generated from protobuf rpc: PlayTrack(player.PlayTrackRequest) returns (player.PlayTrackResponse);
*/
playTrack(
input: PlayTrackRequest,
options?: RpcOptions
): UnaryCall<PlayTrackRequest, PlayTrackResponse> {
const method = this.methods[0],
opt = this._transport.mergeOptions(options);
return stackIntercept<PlayTrackRequest, PlayTrackResponse>(
'unary',
this._transport,
method,
opt,
input
);
}
/**
* @generated from protobuf rpc: ResumeTrack(google.protobuf.Empty) returns (google.protobuf.Empty);
*/
resumeTrack(input: Empty, options?: RpcOptions): UnaryCall<Empty, Empty> {
const method = this.methods[1],
opt = this._transport.mergeOptions(options);
return stackIntercept<Empty, Empty>('unary', this._transport, method, opt, input);
}
/**
* @generated from protobuf rpc: PauseTrack(google.protobuf.Empty) returns (google.protobuf.Empty);
*/
pauseTrack(input: Empty, options?: RpcOptions): UnaryCall<Empty, Empty> {
const method = this.methods[2],
opt = this._transport.mergeOptions(options);
return stackIntercept<Empty, Empty>('unary', this._transport, method, opt, input);
}
}

129
src/lib/proto/player.ts Normal file
View File

@@ -0,0 +1,129 @@
// @generated by protobuf-ts 2.9.4
// @generated from protobuf file "player.proto" (package "player", syntax proto3)
// tslint:disable
import { Empty } from './google/protobuf/empty';
import { ServiceType } from '@protobuf-ts/runtime-rpc';
import type { BinaryWriteOptions } from '@protobuf-ts/runtime';
import type { IBinaryWriter } from '@protobuf-ts/runtime';
import { WireType } from '@protobuf-ts/runtime';
import type { BinaryReadOptions } from '@protobuf-ts/runtime';
import type { IBinaryReader } from '@protobuf-ts/runtime';
import { UnknownFieldHandler } from '@protobuf-ts/runtime';
import type { PartialMessage } from '@protobuf-ts/runtime';
import { reflectionMergePartial } from '@protobuf-ts/runtime';
import { MessageType } from '@protobuf-ts/runtime';
/**
* @generated from protobuf message player.PlayTrackRequest
*/
export interface PlayTrackRequest {
/**
* @generated from protobuf field: string hash = 1;
*/
hash: string;
}
/**
* @generated from protobuf message player.PlayTrackResponse
*/
export interface PlayTrackResponse {}
// @generated message type with reflection information, may provide speed optimized methods
class PlayTrackRequest$Type extends MessageType<PlayTrackRequest> {
constructor() {
super('player.PlayTrackRequest', [
{ no: 1, name: 'hash', kind: 'scalar', T: 9 /*ScalarType.STRING*/ }
]);
}
create(value?: PartialMessage<PlayTrackRequest>): PlayTrackRequest {
const message = globalThis.Object.create(this.messagePrototype!);
message.hash = '';
if (value !== undefined) reflectionMergePartial<PlayTrackRequest>(this, message, value);
return message;
}
internalBinaryRead(
reader: IBinaryReader,
length: number,
options: BinaryReadOptions,
target?: PlayTrackRequest
): PlayTrackRequest {
let message = target ?? this.create(),
end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* string hash */ 1:
message.hash = reader.string();
break;
default:
let u = options.readUnknownField;
if (u === 'throw')
throw new globalThis.Error(
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(
this.typeName,
message,
fieldNo,
wireType,
d
);
}
}
return message;
}
internalBinaryWrite(
message: PlayTrackRequest,
writer: IBinaryWriter,
options: BinaryWriteOptions
): IBinaryWriter {
/* string hash = 1; */
if (message.hash !== '') writer.tag(1, WireType.LengthDelimited).string(message.hash);
let u = options.writeUnknownFields;
if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message player.PlayTrackRequest
*/
export const PlayTrackRequest = new PlayTrackRequest$Type();
// @generated message type with reflection information, may provide speed optimized methods
class PlayTrackResponse$Type extends MessageType<PlayTrackResponse> {
constructor() {
super('player.PlayTrackResponse', []);
}
create(value?: PartialMessage<PlayTrackResponse>): PlayTrackResponse {
const message = globalThis.Object.create(this.messagePrototype!);
if (value !== undefined) reflectionMergePartial<PlayTrackResponse>(this, message, value);
return message;
}
internalBinaryRead(
reader: IBinaryReader,
length: number,
options: BinaryReadOptions,
target?: PlayTrackResponse
): PlayTrackResponse {
return target ?? this.create();
}
internalBinaryWrite(
message: PlayTrackResponse,
writer: IBinaryWriter,
options: BinaryWriteOptions
): IBinaryWriter {
let u = options.writeUnknownFields;
if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message player.PlayTrackResponse
*/
export const PlayTrackResponse = new PlayTrackResponse$Type();
/**
* @generated ServiceType for protobuf service player.Player
*/
export const Player = new ServiceType('player.Player', [
{ name: 'PlayTrack', options: {}, I: PlayTrackRequest, O: PlayTrackResponse },
{ name: 'ResumeTrack', options: {}, I: Empty, O: Empty },
{ name: 'PauseTrack', options: {}, I: Empty, O: Empty }
]);

View File

@@ -0,0 +1,109 @@
// @generated by protobuf-ts 2.9.4
// @generated from protobuf file "settings.proto" (package "settings", syntax proto3)
// tslint:disable
import type { RpcTransport } from '@protobuf-ts/runtime-rpc';
import type { ServiceInfo } from '@protobuf-ts/runtime-rpc';
import { Settings } from './settings';
import type { RefreshPathResponse } from './settings';
import type { RefreshPathRequest } from './settings';
import type { DeletePathResponse } from './settings';
import type { DeletePathRequest } from './settings';
import type { AddPathResponse } from './settings';
import type { AddPathRequest } from './settings';
import { stackIntercept } from '@protobuf-ts/runtime-rpc';
import type { SettingsData } from './settings';
import type { Empty } from './google/protobuf/empty';
import type { UnaryCall } from '@protobuf-ts/runtime-rpc';
import type { RpcOptions } from '@protobuf-ts/runtime-rpc';
/**
* @generated from protobuf service settings.Settings
*/
export interface ISettingsClient {
/**
* @generated from protobuf rpc: ListPaths(google.protobuf.Empty) returns (settings.SettingsData);
*/
listPaths(input: Empty, options?: RpcOptions): UnaryCall<Empty, SettingsData>;
/**
* @generated from protobuf rpc: AddPath(settings.AddPathRequest) returns (settings.AddPathResponse);
*/
addPath(input: AddPathRequest, options?: RpcOptions): UnaryCall<AddPathRequest, AddPathResponse>;
/**
* @generated from protobuf rpc: DeletePath(settings.DeletePathRequest) returns (settings.DeletePathResponse);
*/
deletePath(
input: DeletePathRequest,
options?: RpcOptions
): UnaryCall<DeletePathRequest, DeletePathResponse>;
/**
* @generated from protobuf rpc: RefreshPath(settings.RefreshPathRequest) returns (settings.RefreshPathResponse);
*/
refreshPath(
input: RefreshPathRequest,
options?: RpcOptions
): UnaryCall<RefreshPathRequest, RefreshPathResponse>;
}
/**
* @generated from protobuf service settings.Settings
*/
export class SettingsClient implements ISettingsClient, ServiceInfo {
typeName = Settings.typeName;
methods = Settings.methods;
options = Settings.options;
constructor(private readonly _transport: RpcTransport) {}
/**
* @generated from protobuf rpc: ListPaths(google.protobuf.Empty) returns (settings.SettingsData);
*/
listPaths(input: Empty, options?: RpcOptions): UnaryCall<Empty, SettingsData> {
const method = this.methods[0],
opt = this._transport.mergeOptions(options);
return stackIntercept<Empty, SettingsData>('unary', this._transport, method, opt, input);
}
/**
* @generated from protobuf rpc: AddPath(settings.AddPathRequest) returns (settings.AddPathResponse);
*/
addPath(input: AddPathRequest, options?: RpcOptions): UnaryCall<AddPathRequest, AddPathResponse> {
const method = this.methods[1],
opt = this._transport.mergeOptions(options);
return stackIntercept<AddPathRequest, AddPathResponse>(
'unary',
this._transport,
method,
opt,
input
);
}
/**
* @generated from protobuf rpc: DeletePath(settings.DeletePathRequest) returns (settings.DeletePathResponse);
*/
deletePath(
input: DeletePathRequest,
options?: RpcOptions
): UnaryCall<DeletePathRequest, DeletePathResponse> {
const method = this.methods[2],
opt = this._transport.mergeOptions(options);
return stackIntercept<DeletePathRequest, DeletePathResponse>(
'unary',
this._transport,
method,
opt,
input
);
}
/**
* @generated from protobuf rpc: RefreshPath(settings.RefreshPathRequest) returns (settings.RefreshPathResponse);
*/
refreshPath(
input: RefreshPathRequest,
options?: RpcOptions
): UnaryCall<RefreshPathRequest, RefreshPathResponse> {
const method = this.methods[3],
opt = this._transport.mergeOptions(options);
return stackIntercept<RefreshPathRequest, RefreshPathResponse>(
'unary',
this._transport,
method,
opt,
input
);
}
}

545
src/lib/proto/settings.ts Normal file
View File

@@ -0,0 +1,545 @@
// @generated by protobuf-ts 2.9.4
// @generated from protobuf file "settings.proto" (package "settings", syntax proto3)
// tslint:disable
import { Empty } from './google/protobuf/empty';
import { ServiceType } from '@protobuf-ts/runtime-rpc';
import type { BinaryWriteOptions } from '@protobuf-ts/runtime';
import type { IBinaryWriter } from '@protobuf-ts/runtime';
import { WireType } from '@protobuf-ts/runtime';
import type { BinaryReadOptions } from '@protobuf-ts/runtime';
import type { IBinaryReader } from '@protobuf-ts/runtime';
import { UnknownFieldHandler } from '@protobuf-ts/runtime';
import type { PartialMessage } from '@protobuf-ts/runtime';
import { reflectionMergePartial } from '@protobuf-ts/runtime';
import { MessageType } from '@protobuf-ts/runtime';
/**
* @generated from protobuf message settings.SettingsData
*/
export interface SettingsData {
/**
* @generated from protobuf field: repeated settings.LibraryPath library_paths = 1;
*/
libraryPaths: LibraryPath[];
}
/**
* @generated from protobuf message settings.LibraryPath
*/
export interface LibraryPath {
/**
* @generated from protobuf field: uint64 id = 1;
*/
id: bigint;
/**
* @generated from protobuf field: string path = 2;
*/
path: string;
}
/**
* @generated from protobuf message settings.AddPathRequest
*/
export interface AddPathRequest {
/**
* @generated from protobuf field: string path = 1;
*/
path: string;
}
/**
* @generated from protobuf message settings.AddPathResponse
*/
export interface AddPathResponse {
/**
* @generated from protobuf field: uint64 id = 1;
*/
id: bigint;
}
/**
* @generated from protobuf message settings.DeletePathRequest
*/
export interface DeletePathRequest {
/**
* @generated from protobuf field: uint64 id = 1;
*/
id: bigint;
}
/**
* @generated from protobuf message settings.DeletePathResponse
*/
export interface DeletePathResponse {}
/**
* @generated from protobuf message settings.RefreshPathRequest
*/
export interface RefreshPathRequest {
/**
* @generated from protobuf field: uint64 id = 1;
*/
id: bigint;
}
/**
* @generated from protobuf message settings.RefreshPathResponse
*/
export interface RefreshPathResponse {}
// @generated message type with reflection information, may provide speed optimized methods
class SettingsData$Type extends MessageType<SettingsData> {
constructor() {
super('settings.SettingsData', [
{
no: 1,
name: 'library_paths',
kind: 'message',
repeat: 1 /*RepeatType.PACKED*/,
T: () => LibraryPath
}
]);
}
create(value?: PartialMessage<SettingsData>): SettingsData {
const message = globalThis.Object.create(this.messagePrototype!);
message.libraryPaths = [];
if (value !== undefined) reflectionMergePartial<SettingsData>(this, message, value);
return message;
}
internalBinaryRead(
reader: IBinaryReader,
length: number,
options: BinaryReadOptions,
target?: SettingsData
): SettingsData {
let message = target ?? this.create(),
end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* repeated settings.LibraryPath library_paths */ 1:
message.libraryPaths.push(
LibraryPath.internalBinaryRead(reader, reader.uint32(), options)
);
break;
default:
let u = options.readUnknownField;
if (u === 'throw')
throw new globalThis.Error(
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(
this.typeName,
message,
fieldNo,
wireType,
d
);
}
}
return message;
}
internalBinaryWrite(
message: SettingsData,
writer: IBinaryWriter,
options: BinaryWriteOptions
): IBinaryWriter {
/* repeated settings.LibraryPath library_paths = 1; */
for (let i = 0; i < message.libraryPaths.length; i++)
LibraryPath.internalBinaryWrite(
message.libraryPaths[i],
writer.tag(1, WireType.LengthDelimited).fork(),
options
).join();
let u = options.writeUnknownFields;
if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message settings.SettingsData
*/
export const SettingsData = new SettingsData$Type();
// @generated message type with reflection information, may provide speed optimized methods
class LibraryPath$Type extends MessageType<LibraryPath> {
constructor() {
super('settings.LibraryPath', [
{ no: 1, name: 'id', kind: 'scalar', T: 4 /*ScalarType.UINT64*/, L: 0 /*LongType.BIGINT*/ },
{ no: 2, name: 'path', kind: 'scalar', T: 9 /*ScalarType.STRING*/ }
]);
}
create(value?: PartialMessage<LibraryPath>): LibraryPath {
const message = globalThis.Object.create(this.messagePrototype!);
message.id = 0n;
message.path = '';
if (value !== undefined) reflectionMergePartial<LibraryPath>(this, message, value);
return message;
}
internalBinaryRead(
reader: IBinaryReader,
length: number,
options: BinaryReadOptions,
target?: LibraryPath
): LibraryPath {
let message = target ?? this.create(),
end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* uint64 id */ 1:
message.id = reader.uint64().toBigInt();
break;
case /* string path */ 2:
message.path = reader.string();
break;
default:
let u = options.readUnknownField;
if (u === 'throw')
throw new globalThis.Error(
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(
this.typeName,
message,
fieldNo,
wireType,
d
);
}
}
return message;
}
internalBinaryWrite(
message: LibraryPath,
writer: IBinaryWriter,
options: BinaryWriteOptions
): IBinaryWriter {
/* uint64 id = 1; */
if (message.id !== 0n) writer.tag(1, WireType.Varint).uint64(message.id);
/* string path = 2; */
if (message.path !== '') writer.tag(2, WireType.LengthDelimited).string(message.path);
let u = options.writeUnknownFields;
if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message settings.LibraryPath
*/
export const LibraryPath = new LibraryPath$Type();
// @generated message type with reflection information, may provide speed optimized methods
class AddPathRequest$Type extends MessageType<AddPathRequest> {
constructor() {
super('settings.AddPathRequest', [
{ no: 1, name: 'path', kind: 'scalar', T: 9 /*ScalarType.STRING*/ }
]);
}
create(value?: PartialMessage<AddPathRequest>): AddPathRequest {
const message = globalThis.Object.create(this.messagePrototype!);
message.path = '';
if (value !== undefined) reflectionMergePartial<AddPathRequest>(this, message, value);
return message;
}
internalBinaryRead(
reader: IBinaryReader,
length: number,
options: BinaryReadOptions,
target?: AddPathRequest
): AddPathRequest {
let message = target ?? this.create(),
end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* string path */ 1:
message.path = reader.string();
break;
default:
let u = options.readUnknownField;
if (u === 'throw')
throw new globalThis.Error(
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(
this.typeName,
message,
fieldNo,
wireType,
d
);
}
}
return message;
}
internalBinaryWrite(
message: AddPathRequest,
writer: IBinaryWriter,
options: BinaryWriteOptions
): IBinaryWriter {
/* string path = 1; */
if (message.path !== '') writer.tag(1, WireType.LengthDelimited).string(message.path);
let u = options.writeUnknownFields;
if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message settings.AddPathRequest
*/
export const AddPathRequest = new AddPathRequest$Type();
// @generated message type with reflection information, may provide speed optimized methods
class AddPathResponse$Type extends MessageType<AddPathResponse> {
constructor() {
super('settings.AddPathResponse', [
{ no: 1, name: 'id', kind: 'scalar', T: 4 /*ScalarType.UINT64*/, L: 0 /*LongType.BIGINT*/ }
]);
}
create(value?: PartialMessage<AddPathResponse>): AddPathResponse {
const message = globalThis.Object.create(this.messagePrototype!);
message.id = 0n;
if (value !== undefined) reflectionMergePartial<AddPathResponse>(this, message, value);
return message;
}
internalBinaryRead(
reader: IBinaryReader,
length: number,
options: BinaryReadOptions,
target?: AddPathResponse
): AddPathResponse {
let message = target ?? this.create(),
end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* uint64 id */ 1:
message.id = reader.uint64().toBigInt();
break;
default:
let u = options.readUnknownField;
if (u === 'throw')
throw new globalThis.Error(
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(
this.typeName,
message,
fieldNo,
wireType,
d
);
}
}
return message;
}
internalBinaryWrite(
message: AddPathResponse,
writer: IBinaryWriter,
options: BinaryWriteOptions
): IBinaryWriter {
/* uint64 id = 1; */
if (message.id !== 0n) writer.tag(1, WireType.Varint).uint64(message.id);
let u = options.writeUnknownFields;
if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message settings.AddPathResponse
*/
export const AddPathResponse = new AddPathResponse$Type();
// @generated message type with reflection information, may provide speed optimized methods
class DeletePathRequest$Type extends MessageType<DeletePathRequest> {
constructor() {
super('settings.DeletePathRequest', [
{ no: 1, name: 'id', kind: 'scalar', T: 4 /*ScalarType.UINT64*/, L: 0 /*LongType.BIGINT*/ }
]);
}
create(value?: PartialMessage<DeletePathRequest>): DeletePathRequest {
const message = globalThis.Object.create(this.messagePrototype!);
message.id = 0n;
if (value !== undefined) reflectionMergePartial<DeletePathRequest>(this, message, value);
return message;
}
internalBinaryRead(
reader: IBinaryReader,
length: number,
options: BinaryReadOptions,
target?: DeletePathRequest
): DeletePathRequest {
let message = target ?? this.create(),
end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* uint64 id */ 1:
message.id = reader.uint64().toBigInt();
break;
default:
let u = options.readUnknownField;
if (u === 'throw')
throw new globalThis.Error(
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(
this.typeName,
message,
fieldNo,
wireType,
d
);
}
}
return message;
}
internalBinaryWrite(
message: DeletePathRequest,
writer: IBinaryWriter,
options: BinaryWriteOptions
): IBinaryWriter {
/* uint64 id = 1; */
if (message.id !== 0n) writer.tag(1, WireType.Varint).uint64(message.id);
let u = options.writeUnknownFields;
if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message settings.DeletePathRequest
*/
export const DeletePathRequest = new DeletePathRequest$Type();
// @generated message type with reflection information, may provide speed optimized methods
class DeletePathResponse$Type extends MessageType<DeletePathResponse> {
constructor() {
super('settings.DeletePathResponse', []);
}
create(value?: PartialMessage<DeletePathResponse>): DeletePathResponse {
const message = globalThis.Object.create(this.messagePrototype!);
if (value !== undefined) reflectionMergePartial<DeletePathResponse>(this, message, value);
return message;
}
internalBinaryRead(
reader: IBinaryReader,
length: number,
options: BinaryReadOptions,
target?: DeletePathResponse
): DeletePathResponse {
return target ?? this.create();
}
internalBinaryWrite(
message: DeletePathResponse,
writer: IBinaryWriter,
options: BinaryWriteOptions
): IBinaryWriter {
let u = options.writeUnknownFields;
if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message settings.DeletePathResponse
*/
export const DeletePathResponse = new DeletePathResponse$Type();
// @generated message type with reflection information, may provide speed optimized methods
class RefreshPathRequest$Type extends MessageType<RefreshPathRequest> {
constructor() {
super('settings.RefreshPathRequest', [
{ no: 1, name: 'id', kind: 'scalar', T: 4 /*ScalarType.UINT64*/, L: 0 /*LongType.BIGINT*/ }
]);
}
create(value?: PartialMessage<RefreshPathRequest>): RefreshPathRequest {
const message = globalThis.Object.create(this.messagePrototype!);
message.id = 0n;
if (value !== undefined) reflectionMergePartial<RefreshPathRequest>(this, message, value);
return message;
}
internalBinaryRead(
reader: IBinaryReader,
length: number,
options: BinaryReadOptions,
target?: RefreshPathRequest
): RefreshPathRequest {
let message = target ?? this.create(),
end = reader.pos + length;
while (reader.pos < end) {
let [fieldNo, wireType] = reader.tag();
switch (fieldNo) {
case /* uint64 id */ 1:
message.id = reader.uint64().toBigInt();
break;
default:
let u = options.readUnknownField;
if (u === 'throw')
throw new globalThis.Error(
`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`
);
let d = reader.skip(wireType);
if (u !== false)
(u === true ? UnknownFieldHandler.onRead : u)(
this.typeName,
message,
fieldNo,
wireType,
d
);
}
}
return message;
}
internalBinaryWrite(
message: RefreshPathRequest,
writer: IBinaryWriter,
options: BinaryWriteOptions
): IBinaryWriter {
/* uint64 id = 1; */
if (message.id !== 0n) writer.tag(1, WireType.Varint).uint64(message.id);
let u = options.writeUnknownFields;
if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message settings.RefreshPathRequest
*/
export const RefreshPathRequest = new RefreshPathRequest$Type();
// @generated message type with reflection information, may provide speed optimized methods
class RefreshPathResponse$Type extends MessageType<RefreshPathResponse> {
constructor() {
super('settings.RefreshPathResponse', []);
}
create(value?: PartialMessage<RefreshPathResponse>): RefreshPathResponse {
const message = globalThis.Object.create(this.messagePrototype!);
if (value !== undefined) reflectionMergePartial<RefreshPathResponse>(this, message, value);
return message;
}
internalBinaryRead(
reader: IBinaryReader,
length: number,
options: BinaryReadOptions,
target?: RefreshPathResponse
): RefreshPathResponse {
return target ?? this.create();
}
internalBinaryWrite(
message: RefreshPathResponse,
writer: IBinaryWriter,
options: BinaryWriteOptions
): IBinaryWriter {
let u = options.writeUnknownFields;
if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
return writer;
}
}
/**
* @generated MessageType for protobuf message settings.RefreshPathResponse
*/
export const RefreshPathResponse = new RefreshPathResponse$Type();
/**
* @generated ServiceType for protobuf service settings.Settings
*/
export const Settings = new ServiceType('settings.Settings', [
{ name: 'ListPaths', options: {}, I: Empty, O: SettingsData },
{ name: 'AddPath', options: {}, I: AddPathRequest, O: AddPathResponse },
{ name: 'DeletePath', options: {}, I: DeletePathRequest, O: DeletePathResponse },
{ name: 'RefreshPath', options: {}, I: RefreshPathRequest, O: RefreshPathResponse }
]);

6
src/lib/song.ts Normal file
View File

@@ -0,0 +1,6 @@
export type Song = {
hash: string;
name: string;
artistName: string;
artistId: bigint;
};

4
src/lib/stores/player.ts Normal file
View File

@@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
export const currentlyPlaying = writable<string | null>(null);
export const volume = writable<number>(1.0);

View File

@@ -0,0 +1,8 @@
import { PlayerClient } from '$lib/proto/player.client';
import { protoTransport } from '../hooks.server';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async () => {
// const client = new PlayerClient(protoTransport);
// TODO: Get current song
};

View File

@@ -1,6 +1,31 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import { ModeWatcher } from 'mode-watcher';
import * as Sidebar from '$lib/components/ui/sidebar';
import AppSidebar from '$lib/components/groove/AppSidebar.svelte';
import Footer from '$lib/components/groove/Footer.svelte';
import { ScrollArea } from '$lib/components/ui/scroll-area';
let { children } = $props(); let { children } = $props();
</script> </script>
{@render children()} <ModeWatcher />
<Sidebar.Provider>
<AppSidebar />
<div class="flex max-h-screen flex-auto grow flex-col overflow-hidden">
<main class="grow overflow-hidden">
<Sidebar.Trigger />
<ScrollArea class="flex h-full flex-col overflow-auto">
<div class="m-4 flex flex-col">
<!-- <Sidebar.Trigger /> -->
{@render children()}
</div>
</ScrollArea>
</main>
<Footer />
</div>
</Sidebar.Provider>
<svelte:head><title>Groove</title></svelte:head>

View File

@@ -1,2 +1 @@
<h1>Welcome to SvelteKit</h1> <h1>Home</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>

View File

View File

@@ -0,0 +1 @@
Albums

View File

@@ -0,0 +1,16 @@
import { PlayerClient } from '$lib/proto/player.client';
import { protoTransport } from '../../hooks.server';
import type { Actions } from './$types';
export const actions = {
resume: async () => {
const client = new PlayerClient(protoTransport);
await client.resumeTrack({});
},
pause: async () => {
const client = new PlayerClient(protoTransport);
await client.pauseTrack({});
}
} satisfies Actions;

View File

View File

@@ -0,0 +1 @@
Playlists

View File

@@ -0,0 +1,43 @@
import { SettingsClient } from '$lib/proto/settings.client';
import { fail } from '@sveltejs/kit';
import { protoTransport } from '../../hooks.server';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ depends }) => {
depends('settings:library');
const client = new SettingsClient(protoTransport);
const response = await client.listPaths({});
return {
libraryPaths: response.response.libraryPaths.map((p) => ({
id: p.id,
path: p.path
}))
};
};
export const actions = {
'add-path': async ({ request }) => {
const formData = await request.formData();
const path = formData.get('path')?.toString();
if (!path) {
return fail(400, {
errors: 'Invalid path'
});
}
const client = new SettingsClient(protoTransport);
const response = await client.addPath({
path
});
return {
id: response.response.id
};
}
} satisfies Actions;

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import { Separator } from '$lib/components/ui/separator';
import { Button, buttonVariants } from '$lib/components/ui/button';
import { Plus, Settings } from 'lucide-svelte';
import type { PageServerData } from './$types';
import * as ContextMenu from '$lib/components/ui/context-menu';
import ThemeSwitcher from '$lib/components/groove/ThemeSwitcher.svelte';
import { Input } from '$lib/components/ui/input';
import { enhance } from '$app/forms';
import { Trash, RefreshCw } from 'lucide-svelte';
interface Props {
data: PageServerData;
}
let { data: settings }: Props = $props();
</script>
<h3 class="flex scroll-m-20 items-center text-2xl font-semibold tracking-tight">
<Settings class="mr-2" />Settings
</h3>
<Separator class="mb-5 mt-3" />
<div class="flex flex-col gap-4">
<div class="w-fit space-y-2">
<div class="text-lg font-semibold">
<p>Appearance</p>
<p class="text-sm font-medium leading-none text-muted-foreground">
Change the way Groove looks
</p>
</div>
<ThemeSwitcher />
</div>
<div class="w-fit space-y-2">
<div class="text-lg font-semibold">
<p>Library</p>
<p class="text-sm font-medium leading-none text-muted-foreground">
Add the directories you want to include in your library
</p>
</div>
<form class="flex items-center gap-1" method="POST" action="?/add-path" use:enhance>
<Input name="path" placeholder="~/Music" />
<Button type="submit" class="aspect-square" variant="outline" size="icon"><Plus /></Button>
</form>
<div class="flex flex-col gap-1">
{#each settings.libraryPaths as path}
<div class="group flex items-center justify-between pr-0">
<span>{path.path}</span>
<form class="flex items-center gap-1" method="POST" use:enhance>
<Button
type="submit"
formaction="/settings/path/{path.id}?/refresh"
variant="outline"
size="icon"><RefreshCw /></Button
>
<Button
type="submit"
formaction="/settings/path/{path.id}?/delete"
variant="destructive"
size="icon"><Trash /></Button
>
</form>
</div>
{:else}
<p class="text-sm text-muted-foreground">You have not added a directory yet</p>
{/each}
</div>
</div>
</div>

View File

@@ -0,0 +1,20 @@
import type { Actions } from './$types';
import { SettingsClient } from '$lib/proto/settings.client';
import { protoTransport } from '../../../../hooks.server';
export const actions = {
refresh: async ({ params }) => {
const client = new SettingsClient(protoTransport);
await client.refreshPath({
id: BigInt(params.id)
});
},
delete: async ({ params }) => {
const client = new SettingsClient(protoTransport);
await client.deletePath({
id: BigInt(params.id)
});
}
} satisfies Actions;

View File

@@ -0,0 +1,20 @@
import { LibraryClient } from '$lib/proto/library.client';
import { protoTransport } from '../../hooks.server';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const client = new LibraryClient(protoTransport);
const response = await client.listTracks({});
const tracks = response.response.tracks.map((t) => ({
hash: t.hash,
name: t.name,
artistId: t.artistId,
artistName: t.artistName
}));
return {
songs: tracks
};
};

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import SongListing from '$lib/components/groove/SongListing.svelte';
import type { PageServerData } from './$types';
interface Props {
data: PageServerData;
}
let { data }: Props = $props();
</script>
<div
class="grid grid-cols-2 gap-4 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8"
>
{#each data.songs as song}
<SongListing {song} />
{/each}
</div>

View File

@@ -0,0 +1,13 @@
import { PlayerClient } from '$lib/proto/player.client';
import { protoTransport } from '../../../hooks.server';
import type { Actions } from './$types';
export const actions = {
play: async ({ params }) => {
const client = new PlayerClient(protoTransport);
await client.playTrack({
hash: params.hash
});
}
} satisfies Actions;