chore: formatter use 2 spaces instead of tabs (#273)

* chore: formatter use 2 spaces instead of tabs

* chore: format

* chore: use defaults

* chore: 120 width

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
Brandon Wees
2025-09-18 15:26:01 -04:00
committed by GitHub
co-authored by Jason Rasmussen
parent ebbe63d0f9
commit 7f756355f5
243 changed files with 5585 additions and 5831 deletions
+13 -14
View File
@@ -1,16 +1,15 @@
{ {
"useTabs": true, "singleQuote": true,
"singleQuote": true, "trailingComma": "all",
"trailingComma": "all", "printWidth": 120,
"printWidth": 100, "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], "tailwindFunctions": ["tv"],
"tailwindFunctions": ["tv"], "overrides": [
"overrides": [ {
{ "files": "*.svelte",
"files": "*.svelte", "options": {
"options": { "parser": "svelte"
"parser": "svelte" }
} }
} ]
]
} }
+4 -3
View File
@@ -1,5 +1,6 @@
{ {
"tailwindCSS.experimental.classRegex": [ "tailwindCSS.experimental.classRegex": [["([\"'`][^\"'`]*.*?[\"'`])", "[\"'`]([^\"'`]*).*?[\"'`]"]],
["([\"'`][^\"'`]*.*?[\"'`])", "[\"'`]([^\"'`]*).*?[\"'`]"] "editor.tabSize": 2,
] "editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
} }
+9 -17
View File
@@ -14,26 +14,18 @@ Import components from `@immich/ui`. For example:
```html ```html
<script lang="ts"> <script lang="ts">
import { import { Card, CardBody, CardHeader, CardTitle, CardDescription, Heading, Text } from '@immich/ui';
Card,
CardBody,
CardHeader,
CardTitle,
CardDescription,
Heading,
Text,
} from '@immich/ui';
</script> </script>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>@immich/ui</CardTitle> <CardTitle>@immich/ui</CardTitle>
<CardDescription>A component library</CardDescription> <CardDescription>A component library</CardDescription>
</CardHeader> </CardHeader>
<CardBody> <CardBody>
<Lorem /> <Lorem />
</CardBody> </CardBody>
<CardFooter>Privacy should not be a luxury</CardFooter> <CardFooter>Privacy should not be a luxury</CardFooter>
</Card> </Card>
``` ```
+24 -24
View File
@@ -5,28 +5,28 @@ import globals from 'globals';
import ts from 'typescript-eslint'; import ts from 'typescript-eslint';
export default ts.config( export default ts.config(
js.configs.recommended, js.configs.recommended,
...ts.configs.recommended, ...ts.configs.recommended,
...svelte.configs['flat/recommended'], ...svelte.configs['flat/recommended'],
prettier, prettier,
...svelte.configs['flat/prettier'], ...svelte.configs['flat/prettier'],
{ {
languageOptions: { languageOptions: {
globals: { globals: {
...globals.browser, ...globals.browser,
...globals.node, ...globals.node,
}, },
}, },
}, },
{ {
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
parser: ts.parser, parser: ts.parser,
extraFileExtensions: ['.svelte'], extraFileExtensions: ['.svelte'],
}, },
}, },
}, },
{ {
ignores: ['build/', '.svelte-kit/', 'dist/'], ignores: ['build/', '.svelte-kit/', 'dist/'],
}, },
); );
+77 -77
View File
@@ -1,79 +1,79 @@
{ {
"name": "@immich/ui", "name": "@immich/ui",
"version": "0.30.0", "version": "0.30.0",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"scripts": { "scripts": {
"create": "node scripts/create.js", "create": "node scripts/create.js",
"start": "npm run start:dev", "start": "npm run start:dev",
"start:dev": "vite dev", "start:dev": "vite dev",
"watch": "vite build --watch", "watch": "vite build --watch",
"build": "vite build && npm run package", "build": "vite build && npm run package",
"preview": "vite preview", "preview": "vite preview",
"package": "npm run sync && svelte-package && publint", "package": "npm run sync && svelte-package && publint",
"check": "npm run sync && svelte-check --tsconfig ./tsconfig.json", "check": "npm run sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "npm run sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "npm run sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint . --max-warnings 0", "lint": "eslint . --max-warnings 0",
"lint:fix": "npm run lint -- --fix", "lint:fix": "npm run lint -- --fix",
"format": "prettier --check .", "format": "prettier --check .",
"format:fix": "prettier --write .", "format:fix": "prettier --write .",
"test": "vitest", "test": "vitest",
"sync": "svelte-kit sync" "sync": "svelte-kit sync"
}, },
"files": [ "files": [
"dist", "dist",
"!dist/**/*.test.*", "!dist/**/*.test.*",
"!dist/**/*.spec.*" "!dist/**/*.spec.*"
], ],
"sideEffects": [ "sideEffects": [
"**/*.css" "**/*.css"
], ],
"svelte": "./dist/index.js", "svelte": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"type": "module", "type": "module",
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"svelte": "./dist/index.js" "svelte": "./dist/index.js"
}, },
"./theme/default.css": "./dist/theme/default.css" "./theme/default.css": "./dist/theme/default.css"
}, },
"peerDependencies": { "peerDependencies": {
"svelte": "^5.0.0" "svelte": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.13.0", "@sveltejs/kit": "^2.13.0",
"@sveltejs/package": "^2.0.0", "@sveltejs/package": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0", "@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/postcss": "^4.1.7", "@tailwindcss/postcss": "^4.1.7",
"@tailwindcss/vite": "^4.1.7", "@tailwindcss/vite": "^4.1.7",
"@types/eslint": "^9.6.0", "@types/eslint": "^9.6.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^9.7.0", "eslint": "^9.7.0",
"eslint-config-prettier": "^10.0.0", "eslint-config-prettier": "^10.0.0",
"eslint-plugin-svelte": "^3.9.3", "eslint-plugin-svelte": "^3.9.3",
"globals": "^16.0.0", "globals": "^16.0.0",
"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.11", "prettier-plugin-tailwindcss": "^0.6.11",
"publint": "^0.3.0", "publint": "^0.3.0",
"svelte": "^5.37.0", "svelte": "^5.37.0",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"svelte-highlight": "^7.8.0", "svelte-highlight": "^7.8.0",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"typescript-eslint": "^8.34.1", "typescript-eslint": "^8.34.1",
"vite": "^7.0.0", "vite": "^7.0.0",
"vitest": "^3.0.0" "vitest": "^3.0.0"
}, },
"dependencies": { "dependencies": {
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"bits-ui": "^2.9.8", "bits-ui": "^2.9.8",
"simple-icons": "^15.14.0", "simple-icons": "^15.14.0",
"tailwind-merge": "^3.0.0", "tailwind-merge": "^3.0.0",
"tailwind-variants": "^3.0.0" "tailwind-variants": "^3.0.0"
}, },
"volta": { "volta": {
"node": "22.19.0" "node": "22.19.0"
} }
} }
+3 -3
View File
@@ -1,5 +1,5 @@
export default { export default {
plugins: { plugins: {
'@tailwindcss/postcss': {}, '@tailwindcss/postcss': {},
}, },
}; };
+2 -2
View File
@@ -1,4 +1,4 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["local>immich-app/.github:renovate-config"] "extends": ["local>immich-app/.github:renovate-config"]
} }
+23 -24
View File
@@ -6,8 +6,7 @@ const svelte = String.raw;
/** /**
* @param {string} value * @param {string} value
*/ */
const asSlug = (value) => const asSlug = (value) => value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`).replace(/^-/, '');
value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`).replace(/^-/, '');
/** /**
* @param {string} filename * @param {string} filename
@@ -18,7 +17,7 @@ const readTemplate = (filename) => readFile(join(import.meta.dirname, 'templates
* @param {string} component * @param {string} component
*/ */
const getPageData = (component) => { const getPageData = (component) => {
return svelte` return svelte`
<script lang="ts"> <script lang="ts">
import ComponentExamples from '$docs/components/ComponentExamples.svelte'; import ComponentExamples from '$docs/components/ComponentExamples.svelte';
import ComponentPage from '$docs/components/ComponentPage.svelte'; import ComponentPage from '$docs/components/ComponentPage.svelte';
@@ -36,7 +35,7 @@ const getPageData = (component) => {
* @param {string} component * @param {string} component
*/ */
const getExampleData = (component) => { const getExampleData = (component) => {
return svelte` return svelte`
<script> <script>
import { Text } from '@immich/ui'; import { Text } from '@immich/ui';
</script> </script>
@@ -46,32 +45,32 @@ const getExampleData = (component) => {
}; };
const create = async (component) => { const create = async (component) => {
const componentData = await readTemplate('Component.svelte'); const componentData = await readTemplate('Component.svelte');
const slug = asSlug(component); const slug = asSlug(component);
const files = [ const files = [
{ file: `src/lib/components/${component}/${component}.svelte`, data: componentData }, { file: `src/lib/components/${component}/${component}.svelte`, data: componentData },
{ file: `src/routes/components/${slug}/+page.svelte`, data: getPageData(component) }, { file: `src/routes/components/${slug}/+page.svelte`, data: getPageData(component) },
{ file: `src/routes/components/${slug}/BasicExample.svelte`, data: getExampleData(component) }, { file: `src/routes/components/${slug}/BasicExample.svelte`, data: getExampleData(component) },
]; ];
for (const { file: filepath, data } of files) { for (const { file: filepath, data } of files) {
console.log(`Writing ${filepath}`); console.log(`Writing ${filepath}`);
const directory = dirname(filepath); const directory = dirname(filepath);
await mkdir(directory, { recursive: true }); await mkdir(directory, { recursive: true });
await writeFile(filepath, data); await writeFile(filepath, data);
} }
}; };
const componentName = process.argv[2]; const componentName = process.argv[2];
if (!componentName) { if (!componentName) {
console.log(` usage: node scripts/create.js <component-name>`); console.log(` usage: node scripts/create.js <component-name>`);
process.exit(1); process.exit(1);
} }
create(componentName) create(componentName)
.then(() => process.exit(0)) .then(() => process.exit(0))
.catch((error) => { .catch((error) => {
console.error(error); console.error(error);
process.exit(1); process.exit(1);
}); });
+32 -32
View File
@@ -1,40 +1,40 @@
<script lang="ts"> <script lang="ts">
import type { Color, Size } from '$lib/types.js'; import type { Color, Size } from '$lib/types.js';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { tv } from 'tailwind-variants'; import { tv } from 'tailwind-variants';
type Props = { type Props = {
size?: Size; size?: Size;
color?: Color; color?: Color;
class?: string; class?: string;
children?: Snippet; children?: Snippet;
}; };
const { class: className, color = 'primary', size = 'medium', children }: Props = $props(); const { class: className, color = 'primary', size = 'medium', children }: Props = $props();
const styles = tv({ const styles = tv({
base: '', base: '',
variants: { variants: {
size: { size: {
tiny: 'text-xs', tiny: 'text-xs',
small: 'text-sm', small: 'text-sm',
medium: 'text-base', medium: 'text-base',
large: 'text-lg', large: 'text-lg',
giant: 'text-xl', giant: 'text-xl',
}, },
color: { color: {
primary: 'text-primary', primary: 'text-primary',
secondary: 'text-secondary', secondary: 'text-secondary',
success: 'text-success', success: 'text-success',
danger: 'text-danger', danger: 'text-danger',
warning: 'text-warning', warning: 'text-warning',
info: 'text-info', info: 'text-info',
}, },
}, },
}); });
</script> </script>
<div class={cleanClass(styles({ size, color }), className)}> <div class={cleanClass(styles({ size, color }), className)}>
{@render children?.()} {@render children?.()}
</div> </div>
+2 -2
View File
@@ -1,7 +1,7 @@
body.dark { body.dark {
color-scheme: dark; color-scheme: dark;
} }
body:not(.dark) { body:not(.dark) {
color-scheme: light; color-scheme: light;
} }
+7 -7
View File
@@ -1,13 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts // See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces // for information about these interfaces
declare global { declare global {
namespace App { namespace App {
// interface Error {} // interface Error {}
// interface Locals {} // interface Locals {}
// interface PageData {} // interface PageData {}
// interface PageState {} // interface PageState {}
// interface Platform {} // interface Platform {}
} }
} }
export {}; export {};
+9 -9
View File
@@ -1,12 +1,12 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover" class="dark bg-light text-dark"> <body data-sveltekit-preload-data="hover" class="dark bg-light text-dark">
<div>%sveltekit.body%</div> <div>%sveltekit.body%</div>
</body> </body>
</html> </html>
+15 -15
View File
@@ -1,23 +1,23 @@
<script lang="ts"> <script lang="ts">
import { asComponentHref } from '$docs/utilities.js'; import { asComponentHref } from '$docs/utilities.js';
import { Button, Icon, VStack, Text } from '@immich/ui'; import { Button, Icon, VStack, Text } from '@immich/ui';
type Props = { type Props = {
component: { name: string; icon: string }; component: { name: string; icon: string };
}; };
const { component }: Props = $props(); const { component }: Props = $props();
</script> </script>
<Button <Button
href={asComponentHref(component.name)} href={asComponentHref(component.name)}
variant="outline" variant="outline"
color="secondary" color="secondary"
class="h-24 w-44 rounded-2xl border border-gray-300 dark:border-gray-700" class="h-24 w-44 rounded-2xl border border-gray-300 dark:border-gray-700"
size="large" size="large"
> >
<VStack gap={2}> <VStack gap={2}>
<Icon icon={component.icon} size="1.5rem" aria-hidden /> <Icon icon={component.icon} size="1.5rem" aria-hidden />
<Text size="small">{component.name}</Text> <Text size="small">{component.name}</Text>
</VStack> </VStack>
</Button> </Button>
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { Text } from '@immich/ui'; import { Text } from '@immich/ui';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
type Props = { type Props = {
children: Snippet; children: Snippet;
}; };
const { children }: Props = $props(); const { children }: Props = $props();
</script> </script>
<Text color="muted">{@render children()}</Text> <Text color="muted">{@render children()}</Text>
+44 -52
View File
@@ -1,67 +1,59 @@
<script lang="ts"> <script lang="ts">
import { type ExampleCardProps } from '$docs/constants.js'; import { type ExampleCardProps } from '$docs/constants.js';
import { Button, Card, CardBody, CardHeader, CardTitle, HStack, Theme } from '@immich/ui'; import { Button, Card, CardBody, CardHeader, CardTitle, HStack, Theme } from '@immich/ui';
import { mdiEye, mdiXml } from '@mdi/js'; import { mdiEye, mdiXml } from '@mdi/js';
import { HighlightSvelte, LineNumbers } from 'svelte-highlight'; import { HighlightSvelte, LineNumbers } from 'svelte-highlight';
import atomOneDark from 'svelte-highlight/styles/atom-one-dark'; import atomOneDark from 'svelte-highlight/styles/atom-one-dark';
const { title, component: Component, code, theme }: ExampleCardProps = $props(); const { title, component: Component, code, theme }: ExampleCardProps = $props();
let viewMode = $state<'code' | 'preview'>('preview'); let viewMode = $state<'code' | 'preview'>('preview');
const handleToggle = () => { const handleToggle = () => {
viewMode = viewMode === 'code' ? 'preview' : 'code'; viewMode = viewMode === 'code' ? 'preview' : 'code';
}; };
function getCardBodyClass(viewMode: string, theme?: Theme): string { function getCardBodyClass(viewMode: string, theme?: Theme): string {
if (viewMode === 'code') { if (viewMode === 'code') {
return 'p-0'; return 'p-0';
} }
if (theme === Theme.Light) { if (theme === Theme.Light) {
return 'bg-white dark:bg-white'; return 'bg-white dark:bg-white';
} else if (theme === Theme.Dark) { } else if (theme === Theme.Dark) {
return 'bg-black dark:bg-black'; return 'bg-black dark:bg-black';
} }
return ''; return '';
} }
const cardBodyClass = $derived(getCardBodyClass(viewMode, theme)); const cardBodyClass = $derived(getCardBodyClass(viewMode, theme));
</script> </script>
<svelte:head> <svelte:head>
<!-- eslint-disable-next-line svelte/no-at-html-tags --> <!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html atomOneDark} {@html atomOneDark}
</svelte:head> </svelte:head>
<Card> <Card>
<CardHeader> <CardHeader>
<div class="flex justify-between"> <div class="flex justify-between">
<CardTitle tag="h2">{title}</CardTitle> <CardTitle tag="h2">{title}</CardTitle>
<HStack gap={1}> <HStack gap={1}>
<Button <Button leadingIcon={mdiEye} disabled={viewMode === 'preview'} onclick={handleToggle} size="small"
leadingIcon={mdiEye} >Preview</Button
disabled={viewMode === 'preview'} >
onclick={handleToggle} <Button leadingIcon={mdiXml} disabled={viewMode === 'code'} onclick={handleToggle} size="small">Code</Button>
size="small">Preview</Button </HStack>
> </div>
<Button </CardHeader>
leadingIcon={mdiXml} <CardBody class={cardBodyClass}>
disabled={viewMode === 'code'} {#if viewMode === 'preview'}
onclick={handleToggle} <Component />
size="small">Code</Button {:else}
> <HighlightSvelte code={code.trim().replaceAll(/\t/gm, ' ')} let:highlighted>
</HStack> <LineNumbers {highlighted} hideBorder wrapLines />
</div> </HighlightSvelte>
</CardHeader> {/if}
<CardBody class={cardBodyClass}> </CardBody>
{#if viewMode === 'preview'}
<Component />
{:else}
<HighlightSvelte code={code.trim().replaceAll(/\t/gm, ' ')} let:highlighted>
<LineNumbers {highlighted} hideBorder wrapLines />
</HighlightSvelte>
{/if}
</CardBody>
</Card> </Card>
+10 -10
View File
@@ -1,17 +1,17 @@
<script lang="ts"> <script lang="ts">
import ExampleCard from '$docs/components/ComponentExampleCard.svelte'; import ExampleCard from '$docs/components/ComponentExampleCard.svelte';
import type { ExampleItem } from '$docs/constants.js'; import type { ExampleItem } from '$docs/constants.js';
import { Stack } from '@immich/ui'; import { Stack } from '@immich/ui';
type Props = { type Props = {
examples: ExampleItem[]; examples: ExampleItem[];
}; };
const { examples }: Props = $props(); const { examples }: Props = $props();
</script> </script>
<Stack gap={4} class="mt-4"> <Stack gap={4} class="mt-4">
{#each examples as { title, code, component, theme } (title)} {#each examples as { title, code, component, theme } (title)}
<ExampleCard {title} {code} {component} {theme} /> <ExampleCard {title} {code} {component} {theme} />
{/each} {/each}
</Stack> </Stack>
@@ -1,12 +1,11 @@
<script lang="ts"> <script lang="ts">
import ComponentTipCard from '$docs/components/ComponentTipCard.svelte'; import ComponentTipCard from '$docs/components/ComponentTipCard.svelte';
import { asComponentHref } from '$docs/utilities.js'; import { asComponentHref } from '$docs/utilities.js';
import { Link, Text } from '@immich/ui'; import { Link, Text } from '@immich/ui';
</script> </script>
<ComponentTipCard> <ComponentTipCard>
<Text <Text
>This component can be used with the <Link href={asComponentHref('Field')}>Field</Link> component >This component can be used with the <Link href={asComponentHref('Field')}>Field</Link> component to build forms</Text
to build forms</Text >
>
</ComponentTipCard> </ComponentTipCard>
+7 -7
View File
@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import { Alert } from '@immich/ui'; import { Alert } from '@immich/ui';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
type Props = { type Props = {
children: Snippet; children: Snippet;
}; };
const { children }: Props = $props(); const { children }: Props = $props();
</script> </script>
<Alert color="info" class="mt-4" title="Note"> <Alert color="info" class="mt-4" title="Note">
{@render children()} {@render children()}
</Alert> </Alert>
+23 -23
View File
@@ -1,31 +1,31 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { Container, Heading, type ContainerSize } from '@immich/ui'; import { Container, Heading, type ContainerSize } from '@immich/ui';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
type Props = { type Props = {
name: string; name: string;
size?: ContainerSize; size?: ContainerSize;
children?: Snippet; children?: Snippet;
}; };
const { size = 'medium', name, children }: Props = $props(); const { size = 'medium', name, children }: Props = $props();
</script> </script>
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">
<!-- TODO replace with breadcrumb component --> <!-- TODO replace with breadcrumb component -->
<nav class="bg-light text-dark flex shrink-0 justify-between border-b p-4"> <nav class="bg-light text-dark flex shrink-0 justify-between border-b p-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a href={resolve('/')} class="underline">Home</a> <a href={resolve('/')} class="underline">Home</a>
<span>/</span> <span>/</span>
<a href={resolve('/')} class="underline">Components</a> <a href={resolve('/')} class="underline">Components</a>
<span>/</span> <span>/</span>
<span class="capitalize">{name}</span> <span class="capitalize">{name}</span>
</div> </div>
</nav> </nav>
<Container {size} class="flex flex-col p-4"> <Container {size} class="flex flex-col p-4">
<Heading tag="h1" size="large">{name}</Heading> <Heading tag="h1" size="large">{name}</Heading>
{@render children?.()} {@render children?.()}
</Container> </Container>
</div> </div>
+8 -8
View File
@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { Alert } from '@immich/ui'; import { Alert } from '@immich/ui';
import { mdiLightbulbOnOutline } from '@mdi/js'; import { mdiLightbulbOnOutline } from '@mdi/js';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
type Props = { type Props = {
children: Snippet; children: Snippet;
}; };
const { children }: Props = $props(); const { children }: Props = $props();
</script> </script>
<Alert color="success" class="mt-4" title="Tip" icon={mdiLightbulbOnOutline}> <Alert color="success" class="mt-4" title="Tip" icon={mdiLightbulbOnOutline}>
{@render children()} {@render children()}
</Alert> </Alert>
+38 -38
View File
@@ -1,45 +1,45 @@
<script lang="ts"> <script lang="ts">
import type { Color, Size } from '@immich/ui'; import type { Color, Size } from '@immich/ui';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import { tv } from 'tailwind-variants'; import { tv } from 'tailwind-variants';
type Props = { type Props = {
color?: Color; color?: Color;
width?: Size | 'full'; width?: Size | 'full';
height?: Size | 'full'; height?: Size | 'full';
}; };
const { width = 'medium', height = 'medium', color = 'primary' }: Props = $props(); const { width = 'medium', height = 'medium', color = 'primary' }: Props = $props();
const styles = tv({ const styles = tv({
base: '', base: '',
variants: { variants: {
color: { color: {
primary: 'bg-primary', primary: 'bg-primary',
secondary: 'bg-dark', secondary: 'bg-dark',
success: 'bg-success', success: 'bg-success',
danger: 'bg-danger', danger: 'bg-danger',
warning: 'bg-warning', warning: 'bg-warning',
info: 'bg-info', info: 'bg-info',
}, },
width: { width: {
tiny: 'w-4', tiny: 'w-4',
small: 'w-8', small: 'w-8',
medium: 'w-16', medium: 'w-16',
large: 'w-32', large: 'w-32',
giant: 'w-64', giant: 'w-64',
full: 'w-full', full: 'w-full',
}, },
height: { height: {
tiny: 'h-4', tiny: 'h-4',
small: 'h-8', small: 'h-8',
medium: 'h-16', medium: 'h-16',
large: 'h-32', large: 'h-32',
giant: 'h-64', giant: 'h-64',
full: 'h-full', full: 'h-full',
}, },
}, },
}); });
</script> </script>
<div class={cleanClass(styles({ width, height, color }))}></div> <div class={cleanClass(styles({ width, height, color }))}></div>
+6 -6
View File
@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
type Props = { type Props = {
children: Snippet; children: Snippet;
}; };
const { children }: Props = $props(); const { children }: Props = $props();
</script> </script>
<div class="flex flex-wrap gap-4"> <div class="flex flex-wrap gap-4">
{@render children()} {@render children()}
</div> </div>
+12 -12
View File
@@ -1,19 +1,19 @@
<script lang="ts"> <script lang="ts">
type Props = { count?: number }; type Props = { count?: number };
const { count = 1 }: Props = $props(); const { count = 1 }: Props = $props();
const lorem = const lorem =
'Lorem ipsum dolor sit amet consectetur adipisicing elit. A ipsam tenetur accusantium impedit beatae omnis necessitatibus. Voluptatum blanditiis libero impedit, harum eius inventore nihil, officia voluptate dolorum error consequatur animi.'; 'Lorem ipsum dolor sit amet consectetur adipisicing elit. A ipsam tenetur accusantium impedit beatae omnis necessitatibus. Voluptatum blanditiis libero impedit, harum eius inventore nihil, officia voluptate dolorum error consequatur animi.';
const make = (value: number) => { const make = (value: number) => {
let content = ''; let content = '';
for (let i = 0; i < value; i++) { for (let i = 0; i < value; i++) {
content += lorem; content += lorem;
} }
return content; return content;
}; };
const text = $derived(make(count)); const text = $derived(make(count));
</script> </script>
{text} {text}
+24 -24
View File
@@ -1,31 +1,31 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { IconButton, Logo, Theme } from '@immich/ui'; import { IconButton, Logo, Theme } from '@immich/ui';
import { mdiMenu } from '@mdi/js'; import { mdiMenu } from '@mdi/js';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
type Props = { type Props = {
theme?: Theme; theme?: Theme;
children?: Snippet; children?: Snippet;
onToggleSidebar?: () => void; onToggleSidebar?: () => void;
}; };
const { children, theme = Theme.Dark, onToggleSidebar }: Props = $props(); const { children, theme = Theme.Dark, onToggleSidebar }: Props = $props();
</script> </script>
<nav class="{theme} flex items-center gap-2 p-2"> <nav class="{theme} flex items-center gap-2 p-2">
<IconButton <IconButton
shape="round" shape="round"
color="secondary" color="secondary"
variant="ghost" variant="ghost"
size="medium" size="medium"
aria-label="Main menu" aria-label="Main menu"
icon={mdiMenu} icon={mdiMenu}
onclick={() => onToggleSidebar?.()} onclick={() => onToggleSidebar?.()}
class="md:hidden" class="md:hidden"
/> />
<a href={resolve('/')} class="flex grow gap-2 text-4xl"> <a href={resolve('/')} class="flex grow gap-2 text-4xl">
<Logo variant="inline" /> <Logo variant="inline" />
</a> </a>
{@render children?.()} {@render children?.()}
</nav> </nav>
+108 -108
View File
@@ -1,123 +1,123 @@
import type { Theme } from '@immich/ui'; import type { Theme } from '@immich/ui';
import { import {
mdiAccountCircle, mdiAccountCircle,
mdiAccountCircleOutline, mdiAccountCircleOutline,
mdiAlertCircle, mdiAlertCircle,
mdiAlertCircleOutline, mdiAlertCircleOutline,
mdiApplication, mdiApplication,
mdiApplicationOutline, mdiApplicationOutline,
mdiButtonCursor, mdiButtonCursor,
mdiCard, mdiCard,
mdiCardOutline, mdiCardOutline,
mdiCheckboxMarked, mdiCheckboxMarked,
mdiCheckboxMultipleMarked, mdiCheckboxMultipleMarked,
mdiCheckboxMultipleMarkedOutline, mdiCheckboxMultipleMarkedOutline,
mdiCheckboxOutline, mdiCheckboxOutline,
mdiCloseCircle, mdiCloseCircle,
mdiCloseCircleOutline, mdiCloseCircleOutline,
mdiCodeBraces, mdiCodeBraces,
mdiDotsCircle, mdiDotsCircle,
mdiFormatHeaderPound, mdiFormatHeaderPound,
mdiFormDropdown, mdiFormDropdown,
mdiFormTextarea, mdiFormTextarea,
mdiFormTextbox, mdiFormTextbox,
mdiFormTextboxPassword, mdiFormTextboxPassword,
mdiHelpBox, mdiHelpBox,
mdiHelpBoxOutline, mdiHelpBoxOutline,
mdiHomeCircle, mdiHomeCircle,
mdiHomeCircleOutline, mdiHomeCircleOutline,
mdiImage, mdiImage,
mdiImageOutline, mdiImageOutline,
mdiKeyboardVariant, mdiKeyboardVariant,
mdiLink, mdiLink,
mdiListBox, mdiListBox,
mdiListBoxOutline, mdiListBoxOutline,
mdiMenu, mdiMenu,
mdiNumeric, mdiNumeric,
mdiPanVertical, mdiPanVertical,
mdiPartyPopper, mdiPartyPopper,
mdiSquare, mdiSquare,
mdiSquareOutline, mdiSquareOutline,
mdiThemeLightDark, mdiThemeLightDark,
mdiToggleSwitch, mdiToggleSwitch,
mdiToggleSwitchOutline, mdiToggleSwitchOutline,
mdiViewSequential, mdiViewSequential,
mdiViewSequentialOutline, mdiViewSequentialOutline,
mdiWindowMaximize, mdiWindowMaximize,
} from '@mdi/js'; } from '@mdi/js';
import type { Component } from 'svelte'; import type { Component } from 'svelte';
export enum DisplayOption { export enum DisplayOption {
Both = 'both', Both = 'both',
Light = 'light', Light = 'light',
Dark = 'dark', Dark = 'dark',
} }
export type ExampleItem = { export type ExampleItem = {
title: string; title: string;
code: string; code: string;
component: Component; component: Component;
theme?: Theme; theme?: Theme;
}; };
export type ExampleCardProps = ExampleItem; export type ExampleCardProps = ExampleItem;
export const componentGroups = [ export const componentGroups = [
{ {
title: 'Layout', title: 'Layout',
components: [ components: [
{ name: 'Alert', icon: mdiAlertCircleOutline, activeIcon: mdiAlertCircle }, { name: 'Alert', icon: mdiAlertCircleOutline, activeIcon: mdiAlertCircle },
{ name: 'AppShell', icon: mdiApplicationOutline, activeIcon: mdiApplication }, { name: 'AppShell', icon: mdiApplicationOutline, activeIcon: mdiApplication },
{ name: 'Card', icon: mdiCardOutline, activeIcon: mdiCard }, { name: 'Card', icon: mdiCardOutline, activeIcon: mdiCard },
{ name: 'Container', icon: mdiSquareOutline, activeIcon: mdiSquare }, { name: 'Container', icon: mdiSquareOutline, activeIcon: mdiSquare },
{ name: 'Navbar', icon: mdiMenu }, { name: 'Navbar', icon: mdiMenu },
{ name: 'Modal', icon: mdiWindowMaximize }, { name: 'Modal', icon: mdiWindowMaximize },
{ name: 'ConfirmModal', icon: mdiCheckboxOutline }, { name: 'ConfirmModal', icon: mdiCheckboxOutline },
{ name: 'Scrollable', icon: mdiPanVertical }, { name: 'Scrollable', icon: mdiPanVertical },
{ name: 'Stack', icon: mdiViewSequentialOutline, activeIcon: mdiViewSequential }, { name: 'Stack', icon: mdiViewSequentialOutline, activeIcon: mdiViewSequential },
], ],
}, },
{ {
title: 'Forms', title: 'Forms',
components: [ components: [
{ name: 'Button', icon: mdiButtonCursor }, { name: 'Button', icon: mdiButtonCursor },
{ name: 'IconButton', icon: mdiHomeCircleOutline, activeIcon: mdiHomeCircle }, { name: 'IconButton', icon: mdiHomeCircleOutline, activeIcon: mdiHomeCircle },
{ name: 'Checkbox', icon: mdiCheckboxOutline, activeIcon: mdiCheckboxMarked }, { name: 'Checkbox', icon: mdiCheckboxOutline, activeIcon: mdiCheckboxMarked },
{ name: 'CloseButton', icon: mdiCloseCircleOutline, activeIcon: mdiCloseCircle }, { name: 'CloseButton', icon: mdiCloseCircleOutline, activeIcon: mdiCloseCircle },
{ name: 'Field', icon: mdiListBoxOutline, activeIcon: mdiListBox }, { name: 'Field', icon: mdiListBoxOutline, activeIcon: mdiListBox },
{ name: 'HelperText', icon: mdiHelpBoxOutline, activeIcon: mdiHelpBox }, { name: 'HelperText', icon: mdiHelpBoxOutline, activeIcon: mdiHelpBox },
{ name: 'Input', icon: mdiFormTextbox }, { name: 'Input', icon: mdiFormTextbox },
{ name: 'LoadingSpinner', icon: mdiDotsCircle }, { name: 'LoadingSpinner', icon: mdiDotsCircle },
{ {
name: 'MultiSelect', name: 'MultiSelect',
icon: mdiCheckboxMultipleMarkedOutline, icon: mdiCheckboxMultipleMarkedOutline,
activeIcon: mdiCheckboxMultipleMarked, activeIcon: mdiCheckboxMultipleMarked,
}, },
{ name: 'PasswordInput', icon: mdiFormTextboxPassword }, { name: 'PasswordInput', icon: mdiFormTextboxPassword },
{ name: 'Select', icon: mdiFormDropdown }, { name: 'Select', icon: mdiFormDropdown },
{ name: 'Switch', icon: mdiToggleSwitchOutline, activeIcon: mdiToggleSwitch }, { name: 'Switch', icon: mdiToggleSwitchOutline, activeIcon: mdiToggleSwitch },
], ],
}, },
{ {
title: 'Text', title: 'Text',
components: [ components: [
{ name: 'Code', icon: mdiCodeBraces }, { name: 'Code', icon: mdiCodeBraces },
{ name: 'Kbd', icon: mdiKeyboardVariant }, { name: 'Kbd', icon: mdiKeyboardVariant },
{ name: 'Text', icon: mdiFormatHeaderPound }, { name: 'Text', icon: mdiFormatHeaderPound },
{ name: 'Textarea', icon: mdiFormTextarea }, { name: 'Textarea', icon: mdiFormTextarea },
{ name: 'Heading', icon: mdiFormTextbox }, { name: 'Heading', icon: mdiFormTextbox },
{ name: 'Link', icon: mdiLink }, { name: 'Link', icon: mdiLink },
{ name: 'FormatBytes', icon: mdiNumeric }, { name: 'FormatBytes', icon: mdiNumeric },
], ],
}, },
{ {
title: 'Miscellaneous', title: 'Miscellaneous',
components: [ components: [
{ name: 'Avatar', icon: mdiAccountCircleOutline, activeIcon: mdiAccountCircle }, { name: 'Avatar', icon: mdiAccountCircleOutline, activeIcon: mdiAccountCircle },
{ name: 'Logo', icon: mdiImageOutline, activeIcon: mdiImage }, { name: 'Logo', icon: mdiImageOutline, activeIcon: mdiImage },
{ name: 'CommandPalette', icon: mdiMenu, activeIcon: mdiMenu }, { name: 'CommandPalette', icon: mdiMenu, activeIcon: mdiMenu },
{ name: 'SupporterBadge', icon: mdiPartyPopper }, { name: 'SupporterBadge', icon: mdiPartyPopper },
{ name: 'ThemeSwitcher', icon: mdiThemeLightDark }, { name: 'ThemeSwitcher', icon: mdiThemeLightDark },
], ],
}, },
]; ];
+1 -1
View File
@@ -1,4 +1,4 @@
export const asSlug = (value: string) => export const asSlug = (value: string) =>
value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`).replace(/^-/, ''); value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`).replace(/^-/, '');
export const asComponentHref = (componentName: string) => `/components/${asSlug(componentName)}`; export const asComponentHref = (componentName: string) => `/components/${asSlug(componentName)}`;
+72 -77
View File
@@ -1,39 +1,39 @@
import type { ActionReturn } from 'svelte/action'; import type { ActionReturn } from 'svelte/action';
export type Shortcut = { export type Shortcut = {
key: string; key: string;
alt?: boolean; alt?: boolean;
ctrl?: boolean; ctrl?: boolean;
shift?: boolean; shift?: boolean;
meta?: boolean; meta?: boolean;
}; };
export type ShortcutOptions<T = HTMLElement> = { export type ShortcutOptions<T = HTMLElement> = {
shortcut: Shortcut; shortcut: Shortcut;
/** If true, the event handler will not execute if the event comes from an input field */ /** If true, the event handler will not execute if the event comes from an input field */
ignoreInputFields?: boolean; ignoreInputFields?: boolean;
onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown; onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown;
preventDefault?: boolean; preventDefault?: boolean;
}; };
export const shortcutLabel = (shortcut: Shortcut) => { export const shortcutLabel = (shortcut: Shortcut) => {
let label = ''; let label = '';
if (shortcut.ctrl) { if (shortcut.ctrl) {
label += 'Ctrl '; label += 'Ctrl ';
} }
if (shortcut.alt) { if (shortcut.alt) {
label += 'Alt '; label += 'Alt ';
} }
if (shortcut.meta) { if (shortcut.meta) {
label += 'Cmd '; label += 'Cmd ';
} }
if (shortcut.shift) { if (shortcut.shift) {
label += '⇧'; label += '⇧';
} }
label += shortcut.key.toUpperCase(); label += shortcut.key.toUpperCase();
return label; return label;
}; };
/** Determines whether an event should be ignored. The event will be ignored if: /** Determines whether an event should be ignored. The event will be ignored if:
@@ -41,73 +41,68 @@ export const shortcutLabel = (shortcut: Shortcut) => {
* - The element dispatching the event is an input field * - The element dispatching the event is an input field
*/ */
export const shouldIgnoreEvent = (event: KeyboardEvent | ClipboardEvent): boolean => { export const shouldIgnoreEvent = (event: KeyboardEvent | ClipboardEvent): boolean => {
if (event.target === event.currentTarget) { if (event.target === event.currentTarget) {
return false; return false;
} }
const type = (event.target as HTMLInputElement).type; const type = (event.target as HTMLInputElement).type;
return ['textarea', 'text', 'date', 'datetime-local', 'email', 'password'].includes(type); return ['textarea', 'text', 'date', 'datetime-local', 'email', 'password'].includes(type);
}; };
export const matchesShortcut = (event: KeyboardEvent, shortcut: Shortcut) => { export const matchesShortcut = (event: KeyboardEvent, shortcut: Shortcut) => {
return ( return (
shortcut.key.toLowerCase() === event.key.toLowerCase() && shortcut.key.toLowerCase() === event.key.toLowerCase() &&
Boolean(shortcut.alt) === event.altKey && Boolean(shortcut.alt) === event.altKey &&
Boolean(shortcut.ctrl) === event.ctrlKey && Boolean(shortcut.ctrl) === event.ctrlKey &&
Boolean(shortcut.shift) === event.shiftKey && Boolean(shortcut.shift) === event.shiftKey &&
Boolean(shortcut.meta) === event.metaKey Boolean(shortcut.meta) === event.metaKey
); );
}; };
/** Bind a single keyboard shortcut to node. */ /** Bind a single keyboard shortcut to node. */
export const shortcut = <T extends HTMLElement>( export const shortcut = <T extends HTMLElement>(
node: T, node: T,
option: ShortcutOptions<T>, option: ShortcutOptions<T>,
): ActionReturn<ShortcutOptions<T>> => { ): ActionReturn<ShortcutOptions<T>> => {
const { update: shortcutsUpdate, destroy } = shortcuts(node, [option]); const { update: shortcutsUpdate, destroy } = shortcuts(node, [option]);
return { return {
update(newOption) { update(newOption) {
shortcutsUpdate?.([newOption]); shortcutsUpdate?.([newOption]);
}, },
destroy, destroy,
}; };
}; };
/** Binds multiple keyboard shortcuts to node */ /** Binds multiple keyboard shortcuts to node */
export const shortcuts = <T extends HTMLElement>( export const shortcuts = <T extends HTMLElement>(
node: T, node: T,
options: ShortcutOptions<T>[], options: ShortcutOptions<T>[],
): ActionReturn<ShortcutOptions<T>[]> => { ): ActionReturn<ShortcutOptions<T>[]> => {
function onKeydown(event: KeyboardEvent) { function onKeydown(event: KeyboardEvent) {
const ignoreShortcut = shouldIgnoreEvent(event); const ignoreShortcut = shouldIgnoreEvent(event);
for (const { for (const { shortcut, onShortcut, ignoreInputFields = true, preventDefault = true } of options) {
shortcut, if (ignoreInputFields && ignoreShortcut) {
onShortcut, continue;
ignoreInputFields = true, }
preventDefault = true,
} of options) {
if (ignoreInputFields && ignoreShortcut) {
continue;
}
if (matchesShortcut(event, shortcut)) { if (matchesShortcut(event, shortcut)) {
if (preventDefault) { if (preventDefault) {
event.preventDefault(); event.preventDefault();
} }
onShortcut(event as KeyboardEvent & { currentTarget: T }); onShortcut(event as KeyboardEvent & { currentTarget: T });
return; return;
} }
} }
} }
node.addEventListener('keydown', onKeydown); node.addEventListener('keydown', onKeydown);
return { return {
update(newOptions) { update(newOptions) {
options = newOptions; options = newOptions;
}, },
destroy() { destroy() {
node.removeEventListener('keydown', onKeydown); node.removeEventListener('keydown', onKeydown);
}, },
}; };
}; };
+10 -10
View File
@@ -7,15 +7,15 @@ const fieldKey = Symbol(withPrefix('field'));
export const setFieldContext = (field: FieldContext) => setContext(fieldKey, field); export const setFieldContext = (field: FieldContext) => setContext(fieldKey, field);
export const hasFieldContext = (): boolean => hasContext(fieldKey); export const hasFieldContext = (): boolean => hasContext(fieldKey);
export const getFieldContext = () => { export const getFieldContext = () => {
const { const {
label, label,
color = 'secondary', color = 'secondary',
invalid = false, invalid = false,
readOnly = false, readOnly = false,
required = false, required = false,
disabled = false, disabled = false,
description, description,
} = (getContext(fieldKey) as FieldContext) || {}; } = (getContext(fieldKey) as FieldContext) || {};
return { label, description, color, invalid, readOnly, required, disabled }; return { label, description, color, invalid, readOnly, required, disabled };
}; };
+7 -7
View File
@@ -5,13 +5,13 @@ import { setContext } from 'svelte';
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
export const withChildrenSnippets = (key: ChildKey) => { export const withChildrenSnippets = (key: ChildKey) => {
const map = new SvelteMap<ChildKey, () => ChildData>(); const map = new SvelteMap<ChildKey, () => ChildData>();
setContext(withPrefix(key), { setContext(withPrefix(key), {
register: (child: ChildKey, data: () => ChildData) => map.set(child, data), register: (child: ChildKey, data: () => ChildData) => map.set(child, data),
}); });
return { return {
getChildren: (key: ChildKey) => map.get(key)?.(), getChildren: (key: ChildKey) => map.get(key)?.(),
}; };
}; };
+92 -94
View File
@@ -1,110 +1,108 @@
<script lang="ts"> <script lang="ts">
import Card from '$lib/components/Card/Card.svelte'; import Card from '$lib/components/Card/Card.svelte';
import CardBody from '$lib/components/Card/CardBody.svelte'; import CardBody from '$lib/components/Card/CardBody.svelte';
import CloseButton from '$lib/components/CloseButton/CloseButton.svelte'; import CloseButton from '$lib/components/CloseButton/CloseButton.svelte';
import Icon from '$lib/components/Icon/Icon.svelte'; import Icon from '$lib/components/Icon/Icon.svelte';
import Text from '$lib/components/Text/Text.svelte'; import Text from '$lib/components/Text/Text.svelte';
import type { Color, Size } from '$lib/types.js'; import type { Color, Size } from '$lib/types.js';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import { import {
mdiAlertOutline, mdiAlertOutline,
mdiCheckCircleOutline, mdiCheckCircleOutline,
mdiCloseCircleOutline, mdiCloseCircleOutline,
mdiInformationVariantCircleOutline, mdiInformationVariantCircleOutline,
} from '@mdi/js'; } from '@mdi/js';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
type Props = { type Props = {
color?: Color; color?: Color;
size?: Size; size?: Size;
icon?: string | false; icon?: string | false;
title?: string; title?: string;
class?: string; class?: string;
duration?: number; duration?: number;
closable?: boolean; closable?: boolean;
controlled?: boolean; controlled?: boolean;
onClose?: () => void; onClose?: () => void;
children?: Snippet; children?: Snippet;
}; };
const { const {
color = 'primary', color = 'primary',
icon: iconOverride, icon: iconOverride,
size = 'large', size = 'large',
title, title,
class: className, class: className,
duration, duration,
controlled, controlled,
closable, closable,
onClose, onClose,
children, children,
}: Props = $props(); }: Props = $props();
let open = $state(true); let open = $state(true);
const iconSizes: Record<Size, string> = { const iconSizes: Record<Size, string> = {
tiny: '1em', tiny: '1em',
small: '1.25em', small: '1.25em',
medium: '1.5em', medium: '1.5em',
large: '1.75em', large: '1.75em',
giant: '1.85em', giant: '1.85em',
}; };
const handleClose = () => { const handleClose = () => {
if (!open) { if (!open) {
return; return;
} }
if (!controlled) { if (!controlled) {
open = false; open = false;
} }
onClose?.(); onClose?.();
}; };
if (duration) { if (duration) {
setTimeout(handleClose, duration); setTimeout(handleClose, duration);
} }
const icons: Partial<Record<Color, string>> = { const icons: Partial<Record<Color, string>> = {
success: mdiCheckCircleOutline, success: mdiCheckCircleOutline,
warning: mdiAlertOutline, warning: mdiAlertOutline,
danger: mdiCloseCircleOutline, danger: mdiCloseCircleOutline,
}; };
const icon = $derived( const icon = $derived(
iconOverride === false iconOverride === false ? undefined : iconOverride || (icons[color] ?? mdiInformationVariantCircleOutline),
? undefined );
: iconOverride || (icons[color] ?? mdiInformationVariantCircleOutline),
);
</script> </script>
{#if open} {#if open}
<Card {color} class={cleanClass(className)}> <Card {color} class={cleanClass(className)}>
<CardBody> <CardBody>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class={cleanClass('flex gap-2')}> <div class={cleanClass('flex gap-2')}>
{#if icon} {#if icon}
<div> <div>
<Icon {icon} size={iconSizes[size]} /> <Icon {icon} size={iconSizes[size]} />
</div> </div>
{/if} {/if}
<div class="flex flex-col gap-2 self-center"> <div class="flex flex-col gap-2 self-center">
{#if title} {#if title}
<Text {size} fontWeight={children ? 'bold' : undefined}>{title}</Text> <Text {size} fontWeight={children ? 'bold' : undefined}>{title}</Text>
{/if} {/if}
{#if children} {#if children}
{@render children()} {@render children()}
{/if} {/if}
</div> </div>
</div> </div>
{#if closable || onClose} {#if closable || onClose}
<div> <div>
<CloseButton onclick={handleClose} /> <CloseButton onclick={handleClose} />
</div> </div>
{/if} {/if}
</div> </div>
</CardBody> </CardBody>
</Card> </Card>
{/if} {/if}
+26 -26
View File
@@ -1,34 +1,34 @@
<script lang="ts"> <script lang="ts">
import { withChildrenSnippets } from '$lib/common/use-child.svelte.js'; import { withChildrenSnippets } from '$lib/common/use-child.svelte.js';
import Scrollable from '$lib/components/Scrollable/Scrollable.svelte'; import Scrollable from '$lib/components/Scrollable/Scrollable.svelte';
import { ChildKey } from '$lib/constants.js'; import { ChildKey } from '$lib/constants.js';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
type Props = { type Props = {
class?: string; class?: string;
children?: Snippet; children?: Snippet;
}; };
const { class: className, children }: Props = $props(); const { class: className, children }: Props = $props();
const { getChildren: getChildSnippet } = withChildrenSnippets(ChildKey.AppShell); const { getChildren: getChildSnippet } = withChildrenSnippets(ChildKey.AppShell);
const header = $derived(getChildSnippet(ChildKey.AppShellHeader)); const header = $derived(getChildSnippet(ChildKey.AppShellHeader));
const sidebar = $derived(getChildSnippet(ChildKey.AppShellSidebar)); const sidebar = $derived(getChildSnippet(ChildKey.AppShellSidebar));
</script> </script>
<div class={cleanClass('flex h-dvh flex-col overflow-hidden', className)}> <div class={cleanClass('flex h-dvh flex-col overflow-hidden', className)}>
{#if header} {#if header}
<header class="border-b"> <header class="border-b">
{@render header?.snippet()} {@render header?.snippet()}
</header> </header>
{/if} {/if}
<div class="flex w-full grow overflow-y-auto"> <div class="flex w-full grow overflow-y-auto">
{#if sidebar} {#if sidebar}
{@render sidebar?.snippet()} {@render sidebar?.snippet()}
{/if} {/if}
<Scrollable class="grow" resetOnNavigate> <Scrollable class="grow" resetOnNavigate>
{@render children?.()} {@render children?.()}
</Scrollable> </Scrollable>
</div> </div>
</div> </div>
@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { ChildKey } from '$lib/constants.js'; import { ChildKey } from '$lib/constants.js';
import Child from '$lib/internal/Child.svelte'; import Child from '$lib/internal/Child.svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
type Props = { type Props = {
children: Snippet; children: Snippet;
}; };
let { children }: Props = $props(); let { children }: Props = $props();
</script> </script>
<Child for={ChildKey.AppShell} as={ChildKey.AppShellHeader}> <Child for={ChildKey.AppShell} as={ChildKey.AppShellHeader}>
{@render children?.()} {@render children?.()}
</Child> </Child>
@@ -1,27 +1,27 @@
<script lang="ts"> <script lang="ts">
import Scrollable from '$lib/components/Scrollable/Scrollable.svelte'; import Scrollable from '$lib/components/Scrollable/Scrollable.svelte';
import { ChildKey } from '$lib/constants.js'; import { ChildKey } from '$lib/constants.js';
import Child from '$lib/internal/Child.svelte'; import Child from '$lib/internal/Child.svelte';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import { type Snippet } from 'svelte'; import { type Snippet } from 'svelte';
type Props = { type Props = {
class?: string; class?: string;
children: Snippet; children: Snippet;
open?: boolean; open?: boolean;
}; };
let { class: className, children, open = $bindable(true) }: Props = $props(); let { class: className, children, open = $bindable(true) }: Props = $props();
</script> </script>
<Child for={ChildKey.AppShell} as={ChildKey.AppShellSidebar}> <Child for={ChildKey.AppShell} as={ChildKey.AppShellSidebar}>
<Scrollable <Scrollable
class={cleanClass( class={cleanClass(
'bg-light text-dark relative shrink-0 transition-all duration-200', 'bg-light text-dark relative shrink-0 transition-all duration-200',
open ? 'w-[min(100vw,16rem)]' : 'w-[0px]', open ? 'w-[min(100vw,16rem)]' : 'w-[0px]',
className, className,
)} )}
> >
{@render children?.()} {@render children?.()}
</Scrollable> </Scrollable>
</Child> </Child>
+29 -35
View File
@@ -1,44 +1,38 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
interface Props { interface Props {
title?: string | undefined; title?: string | undefined;
description?: string | undefined; description?: string | undefined;
scrollbar?: boolean; scrollbar?: boolean;
buttons?: Snippet; buttons?: Snippet;
children?: Snippet; children?: Snippet;
} }
let { let { title = undefined, description = undefined, scrollbar = true, buttons, children }: Props = $props();
title = undefined,
description = undefined,
scrollbar = true,
buttons,
children,
}: Props = $props();
let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar p-2 pb-8' : 'scrollbar-hidden'); let scrollbarClass = $derived(scrollbar ? 'immich-scrollbar p-2 pb-8' : 'scrollbar-hidden');
let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full'); let hasTitleClass = $derived(title ? 'top-16 h-[calc(100%-(--spacing(16)))]' : 'top-0 h-full');
</script> </script>
<section class="relative"> <section class="relative">
{#if title || buttons} {#if title || buttons}
<div <div
class="dark:border-immich-neutral dark:text-immich-dark-fg absolute flex h-16 w-full place-items-center justify-between border-b p-4" class="dark:border-immich-neutral dark:text-immich-dark-fg absolute flex h-16 w-full place-items-center justify-between border-b p-4"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if title} {#if title}
<div class="font-medium" tabindex="-1">{title}</div> <div class="font-medium" tabindex="-1">{title}</div>
{/if} {/if}
{#if description} {#if description}
<p class="text-sm text-gray-400 dark:text-gray-600">{description}</p> <p class="text-sm text-gray-400 dark:text-gray-600">{description}</p>
{/if} {/if}
</div> </div>
{@render buttons?.()} {@render buttons?.()}
</div> </div>
{/if} {/if}
<div class="{scrollbarClass} scrollbar-stable absolute {hasTitleClass} w-full overflow-y-auto"> <div class="{scrollbarClass} scrollbar-stable absolute {hasTitleClass} w-full overflow-y-auto">
{@render children?.()} {@render children?.()}
</div> </div>
</section> </section>
+45 -55
View File
@@ -1,66 +1,56 @@
<script lang="ts"> <script lang="ts">
import type { Size } from '$lib/types.js'; import type { Size } from '$lib/types.js';
import { tv } from 'tailwind-variants'; import { tv } from 'tailwind-variants';
type Props = { type Props = {
size?: Size; size?: Size;
color?: color?: 'primary' | 'pink' | 'red' | 'yellow' | 'blue' | 'green' | 'purple' | 'orange' | 'gray' | 'amber';
| 'primary' name: string;
| 'pink' };
| 'red'
| 'yellow'
| 'blue'
| 'green'
| 'purple'
| 'orange'
| 'gray'
| 'amber';
name: string;
};
const { color = 'primary', size = 'medium', name }: Props = $props(); const { color = 'primary', size = 'medium', name }: Props = $props();
const styles = tv({ const styles = tv({
base: 'flex h-full w-full items-center justify-center font-medium select-none', base: 'flex h-full w-full items-center justify-center font-medium select-none',
variants: { variants: {
size: { size: {
tiny: 'h-5 w-5 text-xs', tiny: 'h-5 w-5 text-xs',
small: 'h-7 w-7 text-sm', small: 'h-7 w-7 text-sm',
medium: 'h-10 w-10 text-base', medium: 'h-10 w-10 text-base',
large: 'h-12 w-12 text-lg', large: 'h-12 w-12 text-lg',
giant: 'h-16 w-16 text-xl', giant: 'h-16 w-16 text-xl',
}, },
color: { color: {
primary: 'bg-primary text-light', primary: 'bg-primary text-light',
pink: 'text-light bg-pink-400', pink: 'text-light bg-pink-400',
red: 'text-light bg-red-500', red: 'text-light bg-red-500',
yellow: 'text-light bg-yellow-500', yellow: 'text-light bg-yellow-500',
purple: 'text-dark bg-purple-600', purple: 'text-dark bg-purple-600',
orange: 'text-light bg-orange-600', orange: 'text-light bg-orange-600',
gray: 'text-dark bg-gray-600', gray: 'text-dark bg-gray-600',
amber: 'text-light bg-amber-600', amber: 'text-light bg-amber-600',
blue: 'text-dark bg-blue-500', blue: 'text-dark bg-blue-500',
green: 'text-dark bg-green-600', green: 'text-dark bg-green-600',
}, },
}, },
}); });
const wrapper = tv({ const wrapper = tv({
base: 'overflow-hidden rounded-full shadow-md', base: 'overflow-hidden rounded-full shadow-md',
}); });
const getInitials = (name: string) => { const getInitials = (name: string) => {
return name return name
.split(' ') .split(' ')
.map((part) => part.at(0)) .map((part) => part.at(0))
.join('') .join('')
.substr(0, 2) .substr(0, 2)
.toUpperCase(); .toUpperCase();
}; };
const initials = $derived(getInitials(name)); const initials = $derived(getInitials(name));
</script> </script>
<figure class={wrapper()}> <figure class={wrapper()}>
<span class={styles({ size, color })}>{initials}</span> <span class={styles({ size, color })}>{initials}</span>
</figure> </figure>
+3 -3
View File
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import Button from '$lib/internal/Button.svelte'; import Button from '$lib/internal/Button.svelte';
import type { ButtonProps } from '$lib/types.js'; import type { ButtonProps } from '$lib/types.js';
let { ref = $bindable(null), ...props }: ButtonProps = $props(); let { ref = $bindable(null), ...props }: ButtonProps = $props();
</script> </script>
<Button bind:ref {...props} /> <Button bind:ref {...props} />
+131 -135
View File
@@ -1,154 +1,150 @@
<script lang="ts"> <script lang="ts">
import { withChildrenSnippets } from '$lib/common/use-child.svelte.js'; import { withChildrenSnippets } from '$lib/common/use-child.svelte.js';
import IconButton from '$lib/components/IconButton/IconButton.svelte'; import IconButton from '$lib/components/IconButton/IconButton.svelte';
import Scrollable from '$lib/components/Scrollable/Scrollable.svelte'; import Scrollable from '$lib/components/Scrollable/Scrollable.svelte';
import { ChildKey } from '$lib/constants.js'; import { ChildKey } from '$lib/constants.js';
import type { Color } from '$lib/types.js'; import type { Color } from '$lib/types.js';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import { mdiChevronDown } from '@mdi/js'; import { mdiChevronDown } from '@mdi/js';
import { type Snippet } from 'svelte'; import { type Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { tv } from 'tailwind-variants'; import { tv } from 'tailwind-variants';
type Props = HTMLAttributes<HTMLDivElement> & { type Props = HTMLAttributes<HTMLDivElement> & {
ref?: HTMLElement | null; ref?: HTMLElement | null;
color?: Color; color?: Color;
shape?: 'round' | 'rectangle'; shape?: 'round' | 'rectangle';
expanded?: boolean; expanded?: boolean;
expandable?: boolean; expandable?: boolean;
children: Snippet; children: Snippet;
}; };
let { let {
ref = $bindable(null), ref = $bindable(null),
color, color,
class: className, class: className,
shape = 'round', shape = 'round',
expanded = $bindable(true), expanded = $bindable(true),
expandable = false, expandable = false,
children, children,
...restProps ...restProps
}: Props = $props(); }: Props = $props();
const containerStyles = tv({ const containerStyles = tv({
base: 'w-full overflow-hidden shadow-sm', base: 'w-full overflow-hidden shadow-sm',
variants: { variants: {
shape: { shape: {
rectangle: '', rectangle: '',
round: 'rounded-2xl', round: 'rounded-2xl',
}, },
border: { border: {
true: 'border', true: 'border',
false: '', false: '',
}, },
}, },
}); });
const cardStyles = tv({ const cardStyles = tv({
base: 'flex h-full w-full flex-col', base: 'flex h-full w-full flex-col',
variants: { variants: {
color: { color: {
primary: 'bg-primary/25 dark:bg-primary/25', primary: 'bg-primary/25 dark:bg-primary/25',
secondary: 'text-dark bg-gray-50 dark:bg-neutral-900 dark:text-white', secondary: 'text-dark bg-gray-50 dark:bg-neutral-900 dark:text-white',
success: 'bg-success/15 dark:bg-success/30', success: 'bg-success/15 dark:bg-success/30',
danger: 'bg-danger/15 dark:bg-danger/50', danger: 'bg-danger/15 dark:bg-danger/50',
warning: 'bg-warning/25 dark:bg-warning/50', warning: 'bg-warning/25 dark:bg-warning/50',
info: 'bg-info/25 dark:bg-info/50', info: 'bg-info/25 dark:bg-info/50',
}, },
}, },
}); });
const headerContainerStyles = tv({ const headerContainerStyles = tv({
base: 'p-4', base: 'p-4',
variants: { variants: {
padding: { padding: {
true: '', true: '',
false: 'pb-0', false: 'pb-0',
}, },
border: { border: {
true: 'border-b', true: 'border-b',
false: '', false: '',
}, },
}, },
}); });
const onToggle = () => { const onToggle = () => {
expanded = !expanded; expanded = !expanded;
}; };
const { getChildren: getChildSnippet } = withChildrenSnippets(ChildKey.Card); const { getChildren: getChildSnippet } = withChildrenSnippets(ChildKey.Card);
const headerChild = $derived(getChildSnippet(ChildKey.CardHeader)); const headerChild = $derived(getChildSnippet(ChildKey.CardHeader));
const bodyChild = $derived(getChildSnippet(ChildKey.CardBody)); const bodyChild = $derived(getChildSnippet(ChildKey.CardBody));
const footerChild = $derived(getChildSnippet(ChildKey.CardFooter)); const footerChild = $derived(getChildSnippet(ChildKey.CardFooter));
const headerBorder = $derived(!color); const headerBorder = $derived(!color);
const headerPadding = $derived(headerBorder || !expanded); const headerPadding = $derived(headerBorder || !expanded);
const headerContainerClasses = $derived( const headerContainerClasses = $derived(
twMerge( twMerge(
cleanClass( cleanClass(
headerContainerStyles({ headerContainerStyles({
padding: headerPadding, padding: headerPadding,
border: headerBorder, border: headerBorder,
}), }),
headerChild?.class, headerChild?.class,
), ),
), ),
); );
</script> </script>
{#snippet header()} {#snippet header()}
{#if expandable} {#if expandable}
<button <button
type="button" type="button"
onclick={onToggle} onclick={onToggle}
class={cleanClass('flex w-full items-center justify-between px-4', headerContainerClasses)} class={cleanClass('flex w-full items-center justify-between px-4', headerContainerClasses)}
> >
<div class="flex flex-col"> <div class="flex flex-col">
{@render headerChild?.snippet()} {@render headerChild?.snippet()}
</div> </div>
<div> <div>
<IconButton <IconButton
color="secondary" color="secondary"
icon={mdiChevronDown} icon={mdiChevronDown}
flopped={expanded} flopped={expanded}
variant="ghost" variant="ghost"
shape="round" shape="round"
size="large" size="large"
aria-label="Expand" aria-label="Expand"
/> />
</div> </div>
</button> </button>
{:else} {:else}
<div class={cleanClass('flex flex-col', headerContainerClasses)}> <div class={cleanClass('flex flex-col', headerContainerClasses)}>
{@render headerChild?.snippet()} {@render headerChild?.snippet()}
</div> </div>
{/if} {/if}
{/snippet} {/snippet}
<div <div bind:this={ref} class={cleanClass(containerStyles({ shape, border: !color }), className)} {...restProps}>
bind:this={ref} <div class={cleanClass(cardStyles({ color }))}>
class={cleanClass(containerStyles({ shape, border: !color }), className)} {#if headerChild}
{...restProps} {@render header()}
> {/if}
<div class={cleanClass(cardStyles({ color }))}>
{#if headerChild}
{@render header()}
{/if}
{#if bodyChild && expanded} {#if bodyChild && expanded}
<Scrollable class={twMerge(cleanClass('p-4', bodyChild?.class))}> <Scrollable class={twMerge(cleanClass('p-4', bodyChild?.class))}>
{@render bodyChild?.snippet()} {@render bodyChild?.snippet()}
</Scrollable> </Scrollable>
{/if} {/if}
{#if footerChild} {#if footerChild}
<div class={twMerge(cleanClass('flex items-center border-t p-4', footerChild.class))}> <div class={twMerge(cleanClass('flex items-center border-t p-4', footerChild.class))}>
{@render footerChild.snippet()} {@render footerChild.snippet()}
</div> </div>
{/if} {/if}
{@render children()} {@render children()}
</div> </div>
</div> </div>
+9 -9
View File
@@ -1,16 +1,16 @@
<script lang="ts"> <script lang="ts">
import { ChildKey } from '$lib/constants.js'; import { ChildKey } from '$lib/constants.js';
import Child from '$lib/internal/Child.svelte'; import Child from '$lib/internal/Child.svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
type Props = { type Props = {
class?: string; class?: string;
children: Snippet; children: Snippet;
}; };
let { class: className, children }: Props = $props(); let { class: className, children }: Props = $props();
</script> </script>
<Child for={ChildKey.Card} as={ChildKey.CardBody} class={className}> <Child for={ChildKey.Card} as={ChildKey.CardBody} class={className}>
{@render children?.()} {@render children?.()}
</Child> </Child>
@@ -1,16 +1,16 @@
<script lang="ts"> <script lang="ts">
import Text from '$lib/components/Text/Text.svelte'; import Text from '$lib/components/Text/Text.svelte';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
type Props = { type Props = {
class?: string; class?: string;
children: Snippet; children: Snippet;
}; };
let { children, class: className }: Props = $props(); let { children, class: className }: Props = $props();
</script> </script>
<Text size="small" class={cleanClass('text-dark/75', className)}> <Text size="small" class={cleanClass('text-dark/75', className)}>
{@render children?.()} {@render children?.()}
</Text> </Text>
+9 -9
View File
@@ -1,16 +1,16 @@
<script lang="ts"> <script lang="ts">
import { ChildKey } from '$lib/constants.js'; import { ChildKey } from '$lib/constants.js';
import Child from '$lib/internal/Child.svelte'; import Child from '$lib/internal/Child.svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
type Props = { type Props = {
class?: string; class?: string;
children: Snippet; children: Snippet;
}; };
let { class: className, children }: Props = $props(); let { class: className, children }: Props = $props();
</script> </script>
<Child for={ChildKey.Card} as={ChildKey.CardFooter} class={className}> <Child for={ChildKey.Card} as={ChildKey.CardFooter} class={className}>
{@render children?.()} {@render children?.()}
</Child> </Child>
+9 -9
View File
@@ -1,16 +1,16 @@
<script lang="ts"> <script lang="ts">
import { ChildKey } from '$lib/constants.js'; import { ChildKey } from '$lib/constants.js';
import Child from '$lib/internal/Child.svelte'; import Child from '$lib/internal/Child.svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
type Props = { type Props = {
class?: string; class?: string;
children: Snippet; children: Snippet;
}; };
let { class: className, children }: Props = $props(); let { class: className, children }: Props = $props();
</script> </script>
<Child for={ChildKey.Card} as={ChildKey.CardHeader} class={className}> <Child for={ChildKey.Card} as={ChildKey.CardHeader} class={className}>
{@render children?.()} {@render children?.()}
</Child> </Child>
+14 -14
View File
@@ -1,21 +1,21 @@
<script lang="ts"> <script lang="ts">
import Heading from '$lib/components/Heading/Heading.svelte'; import Heading from '$lib/components/Heading/Heading.svelte';
import type { HeadingSize, HeadingTag } from '$lib/types.js'; import type { HeadingSize, HeadingTag } from '$lib/types.js';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
type Props = { type Props = {
/** /**
* The HTML element type. * The HTML element type.
*/ */
tag?: HeadingTag; tag?: HeadingTag;
class?: string; class?: string;
size?: HeadingSize; size?: HeadingSize;
children: Snippet; children: Snippet;
}; };
const { size = 'small', tag, class: className, children }: Props = $props(); const { size = 'small', tag, class: className, children }: Props = $props();
</script> </script>
<Heading {tag} {size} class={className}> <Heading {tag} {size} class={className}>
{@render children?.()} {@render children?.()}
</Heading> </Heading>
+109 -110
View File
@@ -1,122 +1,121 @@
<script lang="ts"> <script lang="ts">
import { getFieldContext } from '$lib/common/context.svelte.js'; import { getFieldContext } from '$lib/common/context.svelte.js';
import Icon from '$lib/components/Icon/Icon.svelte'; import Icon from '$lib/components/Icon/Icon.svelte';
import Label from '$lib/components/Label/Label.svelte'; import Label from '$lib/components/Label/Label.svelte';
import Text from '$lib/components/Text/Text.svelte'; import Text from '$lib/components/Text/Text.svelte';
import type { Color, Shape, Size } from '$lib/types.js'; import type { Color, Shape, Size } from '$lib/types.js';
import { cleanClass, generateId } from '$lib/utils.js'; import { cleanClass, generateId } from '$lib/utils.js';
import { mdiCheck, mdiMinus } from '@mdi/js'; import { mdiCheck, mdiMinus } from '@mdi/js';
import { Checkbox as CheckboxPrimitive, type WithoutChildrenOrChild } from 'bits-ui'; import { Checkbox as CheckboxPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import { tv } from 'tailwind-variants'; import { tv } from 'tailwind-variants';
type CheckboxProps = WithoutChildrenOrChild<CheckboxPrimitive.RootProps> & { type CheckboxProps = WithoutChildrenOrChild<CheckboxPrimitive.RootProps> & {
color?: Color; color?: Color;
shape?: Shape; shape?: Shape;
size?: Size; size?: Size;
}; };
let { let {
ref = $bindable(null), ref = $bindable(null),
checked = $bindable(false), checked = $bindable(false),
class: className, class: className,
color = 'primary', color = 'primary',
shape = 'semi-round', shape = 'semi-round',
size = 'small', size = 'small',
...restProps ...restProps
}: CheckboxProps = $props(); }: CheckboxProps = $props();
const { readOnly, required, invalid, disabled, label, description, ...labelProps } = const { readOnly, required, invalid, disabled, label, description, ...labelProps } = $derived(getFieldContext());
$derived(getFieldContext());
const containerStyles = tv({ const containerStyles = tv({
base: 'ring-offset-background focus-visible:ring-ring peer data-[state=checked]:bg-primary box-content overflow-hidden border-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50', base: 'ring-offset-background focus-visible:ring-ring peer data-[state=checked]:bg-primary box-content overflow-hidden border-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50',
variants: { variants: {
shape: { shape: {
rectangle: 'rounded-none', rectangle: 'rounded-none',
'semi-round': 'rounded-lg', 'semi-round': 'rounded-lg',
round: 'rounded-full', round: 'rounded-full',
}, },
color: { color: {
primary: 'border-primary', primary: 'border-primary',
secondary: 'border-dark', secondary: 'border-dark',
success: 'border-success', success: 'border-success',
danger: 'border-danger', danger: 'border-danger',
warning: 'border-warning', warning: 'border-warning',
info: 'border-info', info: 'border-info',
}, },
size: { size: {
tiny: 'size-4', tiny: 'size-4',
small: 'size-5', small: 'size-5',
medium: 'size-6', medium: 'size-6',
large: 'size-8', large: 'size-8',
giant: 'size-10', giant: 'size-10',
}, },
roundedSize: { roundedSize: {
tiny: 'rounded-md', tiny: 'rounded-md',
small: 'rounded-md', small: 'rounded-md',
medium: 'rounded-md', medium: 'rounded-md',
large: 'rounded-lg', large: 'rounded-lg',
giant: 'rounded-xl', giant: 'rounded-xl',
}, },
}, },
}); });
const iconStyles = tv({ const iconStyles = tv({
variants: { variants: {
fullWidth: { fullWidth: {
true: 'w-full', true: 'w-full',
}, },
color: { color: {
primary: 'bg-primary text-light hover:bg-primary/80', primary: 'bg-primary text-light hover:bg-primary/80',
secondary: 'bg-dark text-light hover:bg-dark/80', secondary: 'bg-dark text-light hover:bg-dark/80',
success: 'bg-success text-light hover:bg-success/80', success: 'bg-success text-light hover:bg-success/80',
danger: 'bg-danger text-light hover:bg-danger/80', danger: 'bg-danger text-light hover:bg-danger/80',
warning: 'bg-warning text-light hover:bg-warning/80', warning: 'bg-warning text-light hover:bg-warning/80',
info: 'bg-info text-light hover:bg-info/80', info: 'bg-info text-light hover:bg-info/80',
}, },
}, },
}); });
const id = generateId(); const id = generateId();
const inputId = `input-${id}`; const inputId = `input-${id}`;
const labelId = `label-${id}`; const labelId = `label-${id}`;
const descriptionId = $derived(description ? `description-${id}` : undefined); const descriptionId = $derived(description ? `description-${id}` : undefined);
</script> </script>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
{#if label} {#if label}
<Label id={labelId} for={inputId} {label} {...labelProps} /> <Label id={labelId} for={inputId} {label} {...labelProps} />
{#if description} {#if description}
<Text color="secondary" size="small" id={descriptionId}>{description}</Text> <Text color="secondary" size="small" id={descriptionId}>{description}</Text>
{/if} {/if}
{/if} {/if}
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
bind:ref bind:ref
class={cleanClass( class={cleanClass(
containerStyles({ containerStyles({
size, size,
color: invalid ? 'danger' : color, color: invalid ? 'danger' : color,
shape, shape,
roundedSize: shape === 'semi-round' ? size : undefined, roundedSize: shape === 'semi-round' ? size : undefined,
}), }),
className, className,
)} )}
bind:checked bind:checked
disabled={disabled || readOnly} disabled={disabled || readOnly}
{required} {required}
aria-readonly={disabled || readOnly} aria-readonly={disabled || readOnly}
aria-describedby={descriptionId} aria-describedby={descriptionId}
{...restProps} {...restProps}
> >
{#snippet children({ checked, indeterminate })} {#snippet children({ checked, indeterminate })}
<div class={cleanClass('flex items-center justify-center text-current')}> <div class={cleanClass('flex items-center justify-center text-current')}>
{#if indeterminate} {#if indeterminate}
<Icon icon={mdiMinus} size="100%" class={cleanClass(iconStyles({ color }))} /> <Icon icon={mdiMinus} size="100%" class={cleanClass(iconStyles({ color }))} />
{:else if checked} {:else if checked}
<Icon icon={mdiCheck} size="100%" class={cleanClass(iconStyles({ color }))} /> <Icon icon={mdiCheck} size="100%" class={cleanClass(iconStyles({ color }))} />
{/if} {/if}
</div> </div>
{/snippet} {/snippet}
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
</div> </div>
@@ -1,23 +1,18 @@
<script lang="ts"> <script lang="ts">
import IconButton from '$lib/components/IconButton/IconButton.svelte'; import IconButton from '$lib/components/IconButton/IconButton.svelte';
import { t } from '$lib/services/translation.svelte.js'; import { t } from '$lib/services/translation.svelte.js';
import type { CloseButtonProps } from '$lib/types.js'; import type { CloseButtonProps } from '$lib/types.js';
import { mdiClose } from '@mdi/js'; import { mdiClose } from '@mdi/js';
const { const { size = 'medium', variant = 'ghost', translations, ...restProps }: CloseButtonProps = $props();
size = 'medium',
variant = 'ghost',
translations,
...restProps
}: CloseButtonProps = $props();
</script> </script>
<IconButton <IconButton
{...restProps} {...restProps}
icon={mdiClose} icon={mdiClose}
shape="round" shape="round"
{variant} {variant}
{size} {size}
color="secondary" color="secondary"
aria-label={t('close', translations)} aria-label={t('close', translations)}
/> />
+58 -65
View File
@@ -1,76 +1,69 @@
<script lang="ts"> <script lang="ts">
import type { Size, TextColor } from '$lib/types.js'; import type { Size, TextColor } from '$lib/types.js';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
import { tv } from 'tailwind-variants'; import { tv } from 'tailwind-variants';
type Props = { type Props = {
color?: TextColor; color?: TextColor;
size?: Size; size?: Size;
variant?: 'filled'; variant?: 'filled';
class?: string; class?: string;
children: Snippet; children: Snippet;
} & HTMLAttributes<HTMLElement>; } & HTMLAttributes<HTMLElement>;
const { const { class: className, size = 'medium', variant, color = 'secondary', children, ...restProps }: Props = $props();
class: className,
size = 'medium',
variant,
color = 'secondary',
children,
...restProps
}: Props = $props();
const styles = tv({ const styles = tv({
base: 'font-monospace', base: 'font-monospace',
variants: { variants: {
textColor: { textColor: {
muted: 'text-gray-600 dark:text-gray-400', muted: 'text-gray-600 dark:text-gray-400',
primary: 'text-primary', primary: 'text-primary',
secondary: 'text-dark', secondary: 'text-dark',
success: 'text-success', success: 'text-success',
danger: 'text-danger', danger: 'text-danger',
warning: 'text-warning', warning: 'text-warning',
info: 'text-info', info: 'text-info',
}, },
filled: { filled: {
true: 'rounded-lg p-3', true: 'rounded-lg p-3',
false: '', false: '',
}, },
filledColor: { filledColor: {
false: '', false: '',
muted: 'text-light bg-gray-600 dark:bg-gray-800', muted: 'text-light bg-gray-600 dark:bg-gray-800',
primary: 'text-primary dark:bg-primary/20 bg-gray-200 dark:text-gray-200', primary: 'text-primary dark:bg-primary/20 bg-gray-200 dark:text-gray-200',
secondary: 'text-light bg-gray-700 dark:bg-gray-200', secondary: 'text-light bg-gray-700 dark:bg-gray-200',
success: 'bg-success text-light', success: 'bg-success text-light',
danger: 'bg-danger text-light', danger: 'bg-danger text-light',
warning: 'bg-warning text-light', warning: 'bg-warning text-light',
info: 'bg-info text-dark', info: 'bg-info text-dark',
}, },
size: { size: {
tiny: 'text-xs', tiny: 'text-xs',
small: 'text-sm', small: 'text-sm',
medium: 'text-base', medium: 'text-base',
large: 'text-lg', large: 'text-lg',
giant: 'text-xl', giant: 'text-xl',
}, },
}, },
}); });
</script> </script>
<code <code
class={cleanClass( class={cleanClass(
styles({ styles({
filled: variant === 'filled', filled: variant === 'filled',
filledColor: variant === 'filled' && color, filledColor: variant === 'filled' && color,
textColor: color, textColor: color,
size, size,
}), }),
className, className,
)} )}
{...restProps}>{@render children()}</code {...restProps}>{@render children()}</code
> >
@@ -1,150 +1,145 @@
<script lang="ts"> <script lang="ts">
import { shortcuts } from '$lib/actions/shortcut.js'; import { shortcuts } from '$lib/actions/shortcut.js';
import CloseButton from '$lib/components/CloseButton/CloseButton.svelte'; import CloseButton from '$lib/components/CloseButton/CloseButton.svelte';
import CommandPaletteItem from '$lib/components/CommandPalette/CommandPaletteItem.svelte'; import CommandPaletteItem from '$lib/components/CommandPalette/CommandPaletteItem.svelte';
import Icon from '$lib/components/Icon/Icon.svelte'; import Icon from '$lib/components/Icon/Icon.svelte';
import Input from '$lib/components/Input/Input.svelte'; import Input from '$lib/components/Input/Input.svelte';
import Modal from '$lib/components/Modal/Modal.svelte'; import Modal from '$lib/components/Modal/Modal.svelte';
import ModalBody from '$lib/components/Modal/ModalBody.svelte'; import ModalBody from '$lib/components/Modal/ModalBody.svelte';
import ModalFooter from '$lib/components/Modal/ModalFooter.svelte'; import ModalFooter from '$lib/components/Modal/ModalFooter.svelte';
import ModalHeader from '$lib/components/Modal/ModalHeader.svelte'; import ModalHeader from '$lib/components/Modal/ModalHeader.svelte';
import Stack from '$lib/components/Stack/Stack.svelte'; import Stack from '$lib/components/Stack/Stack.svelte';
import Text from '$lib/components/Text/Text.svelte'; import Text from '$lib/components/Text/Text.svelte';
import { commandPaletteManager } from '$lib/services/command-palette-manager.svelte'; import { commandPaletteManager } from '$lib/services/command-palette-manager.svelte';
import { t } from '$lib/services/translation.svelte.js'; import { t } from '$lib/services/translation.svelte.js';
import type { TranslationProps } from '$lib/types.js'; import type { TranslationProps } from '$lib/types.js';
import { mdiArrowDown, mdiArrowUp, mdiKeyboardEsc, mdiKeyboardReturn, mdiMagnify } from '@mdi/js'; import { mdiArrowDown, mdiArrowUp, mdiKeyboardEsc, mdiKeyboardReturn, mdiMagnify } from '@mdi/js';
type Props = { type Props = {
translations?: TranslationProps< translations?: TranslationProps<
| 'search_placeholder' 'search_placeholder' | 'search_no_results' | 'search_recently_used' | 'command_palette_prompt_default'
| 'search_no_results' >;
| 'search_recently_used' };
| 'command_palette_prompt_default'
>;
};
let { translations }: Props = $props(); let { translations }: Props = $props();
let inputElement = $state<HTMLInputElement | null>(null); let inputElement = $state<HTMLInputElement | null>(null);
const handleOpen = () => commandPaletteManager.open(); const handleOpen = () => commandPaletteManager.open();
const handleClose = () => commandPaletteManager.close(); const handleClose = () => commandPaletteManager.close();
const handleUp = (event: KeyboardEvent) => handleNavigate(event, 'up'); const handleUp = (event: KeyboardEvent) => handleNavigate(event, 'up');
const handleDown = (event: KeyboardEvent) => handleNavigate(event, 'down'); const handleDown = (event: KeyboardEvent) => handleNavigate(event, 'down');
const handleSelect = (event: KeyboardEvent) => handleNavigate(event, 'select'); const handleSelect = (event: KeyboardEvent) => handleNavigate(event, 'select');
const handleNavigate = async (event: KeyboardEvent, direction: 'up' | 'down' | 'select') => { const handleNavigate = async (event: KeyboardEvent, direction: 'up' | 'down' | 'select') => {
if (!commandPaletteManager.isOpen) { if (!commandPaletteManager.isOpen) {
return; return;
} }
event.preventDefault(); event.preventDefault();
switch (direction) { switch (direction) {
case 'up': { case 'up': {
commandPaletteManager.up(); commandPaletteManager.up();
break; break;
} }
case 'down': { case 'down': {
commandPaletteManager.down(); commandPaletteManager.down();
break; break;
} }
case 'select': { case 'select': {
await commandPaletteManager.select(); await commandPaletteManager.select();
break; break;
} }
} }
}; };
</script> </script>
<svelte:window <svelte:window
use:shortcuts={[ use:shortcuts={[
{ shortcut: { key: 'k', meta: true }, preventDefault: true, onShortcut: handleOpen }, { shortcut: { key: 'k', meta: true }, preventDefault: true, onShortcut: handleOpen },
{ shortcut: { key: 'k', ctrl: true }, preventDefault: true, onShortcut: handleOpen }, { shortcut: { key: 'k', ctrl: true }, preventDefault: true, onShortcut: handleOpen },
{ shortcut: { key: '/' }, preventDefault: true, onShortcut: handleOpen }, { shortcut: { key: '/' }, preventDefault: true, onShortcut: handleOpen },
{ shortcut: { key: 'ArrowUp' }, ignoreInputFields: false, onShortcut: handleUp }, { shortcut: { key: 'ArrowUp' }, ignoreInputFields: false, onShortcut: handleUp },
{ shortcut: { key: 'ArrowDown' }, ignoreInputFields: false, onShortcut: handleDown }, { shortcut: { key: 'ArrowDown' }, ignoreInputFields: false, onShortcut: handleDown },
{ shortcut: { key: 'k', ctrl: true }, ignoreInputFields: false, onShortcut: handleUp }, { shortcut: { key: 'k', ctrl: true }, ignoreInputFields: false, onShortcut: handleUp },
{ shortcut: { key: 'k', meta: true }, ignoreInputFields: false, onShortcut: handleUp }, { shortcut: { key: 'k', meta: true }, ignoreInputFields: false, onShortcut: handleUp },
{ shortcut: { key: 'j', ctrl: true }, ignoreInputFields: false, onShortcut: handleDown }, { shortcut: { key: 'j', ctrl: true }, ignoreInputFields: false, onShortcut: handleDown },
{ shortcut: { key: 'j', meta: true }, ignoreInputFields: false, onShortcut: handleDown }, { shortcut: { key: 'j', meta: true }, ignoreInputFields: false, onShortcut: handleDown },
{ shortcut: { key: 'Enter' }, ignoreInputFields: false, onShortcut: handleSelect }, { shortcut: { key: 'Enter' }, ignoreInputFields: false, onShortcut: handleSelect },
{ shortcut: { key: 'Escape' }, onShortcut: handleClose }, { shortcut: { key: 'Escape' }, onShortcut: handleClose },
]} ]}
/> />
{#if commandPaletteManager.isOpen} {#if commandPaletteManager.isOpen}
<Modal size="large" onClose={handleClose} closeOnBackdropClick> <Modal size="large" onClose={handleClose} closeOnBackdropClick>
<ModalHeader> <ModalHeader>
<div class="flex place-items-center gap-1"> <div class="flex place-items-center gap-1">
<Input <Input
bind:ref={inputElement} bind:ref={inputElement}
bind:value={commandPaletteManager.query} bind:value={commandPaletteManager.query}
placeholder={t('search_placeholder', translations)} placeholder={t('search_placeholder', translations)}
leadingIcon={mdiMagnify} leadingIcon={mdiMagnify}
tabindex={1} tabindex={1}
/> />
<div> <div>
<CloseButton onclick={() => commandPaletteManager.close()} class="md:hidden" /> <CloseButton onclick={() => commandPaletteManager.close()} class="md:hidden" />
</div> </div>
</div> </div>
</ModalHeader> </ModalHeader>
<ModalBody> <ModalBody>
<Stack gap={2}> <Stack gap={2}>
{#if commandPaletteManager.query} {#if commandPaletteManager.query}
{#if commandPaletteManager.results.length === 0} {#if commandPaletteManager.results.length === 0}
<Text>{t('search_no_results', translations)}</Text> <Text>{t('search_no_results', translations)}</Text>
{/if} {/if}
{:else if commandPaletteManager.recentItems.length > 0} {:else if commandPaletteManager.recentItems.length > 0}
<Text>{t('search_recently_used', translations)}</Text> <Text>{t('search_recently_used', translations)}</Text>
{:else} {:else}
<Text>{t('command_palette_prompt_default', translations)}</Text> <Text>{t('command_palette_prompt_default', translations)}</Text>
{/if} {/if}
{#if commandPaletteManager.results.length > 0} {#if commandPaletteManager.results.length > 0}
<div class="flex flex-col"> <div class="flex flex-col">
{#each commandPaletteManager.results as item, i (i)} {#each commandPaletteManager.results as item, i (i)}
<CommandPaletteItem <CommandPaletteItem
{item} {item}
selected={commandPaletteManager.selectedIndex === i} selected={commandPaletteManager.selectedIndex === i}
onRemove={commandPaletteManager.query onRemove={commandPaletteManager.query ? undefined : () => commandPaletteManager.remove(i)}
? undefined onSelect={() => commandPaletteManager.select(i)}
: () => commandPaletteManager.remove(i)} />
onSelect={() => commandPaletteManager.select(i)} {/each}
/> </div>
{/each} {/if}
</div> </Stack>
{/if} </ModalBody>
</Stack> <ModalFooter>
</ModalBody> <div class="flex w-full justify-around">
<ModalFooter> <div class="flex gap-4">
<div class="flex w-full justify-around"> <div class="flex place-items-center gap-1">
<div class="flex gap-4"> <span class="rounded bg-gray-300 p-1 dark:bg-gray-500">
<div class="flex place-items-center gap-1"> <Icon icon={mdiKeyboardReturn} size="1rem" />
<span class="rounded bg-gray-300 p-1 dark:bg-gray-500"> </span>
<Icon icon={mdiKeyboardReturn} size="1rem" /> <Text size="small">to select</Text>
</span> </div>
<Text size="small">to select</Text>
</div>
<div class="flex place-items-center gap-1"> <div class="flex place-items-center gap-1">
<span class="flex gap-1 rounded bg-gray-300 p-1 dark:bg-gray-500"> <span class="flex gap-1 rounded bg-gray-300 p-1 dark:bg-gray-500">
<Icon icon={mdiArrowUp} size="1rem" /> <Icon icon={mdiArrowUp} size="1rem" />
<Icon icon={mdiArrowDown} size="1rem" /> <Icon icon={mdiArrowDown} size="1rem" />
</span> </span>
<Text size="small">to navigate</Text> <Text size="small">to navigate</Text>
</div> </div>
<div class="flex place-items-center gap-1"> <div class="flex place-items-center gap-1">
<span class="rounded bg-gray-300 p-1 dark:bg-gray-500"> <span class="rounded bg-gray-300 p-1 dark:bg-gray-500">
<Icon icon={mdiKeyboardEsc} size="1rem" /> <Icon icon={mdiKeyboardEsc} size="1rem" />
</span> </span>
<Text size="small">to close</Text> <Text size="small">to close</Text>
</div> </div>
</div> </div>
</div> </div>
</ModalFooter> </ModalFooter>
</Modal> </Modal>
{/if} {/if}
@@ -1,71 +1,71 @@
<script lang="ts"> <script lang="ts">
import Button from '$lib/components/Button/Button.svelte'; import Button from '$lib/components/Button/Button.svelte';
import Icon from '$lib/components/Icon/Icon.svelte'; import Icon from '$lib/components/Icon/Icon.svelte';
import IconButton from '$lib/components/IconButton/IconButton.svelte'; import IconButton from '$lib/components/IconButton/IconButton.svelte';
import Text from '$lib/components/Text/Text.svelte'; import Text from '$lib/components/Text/Text.svelte';
import type { CommandItem } from '$lib/services/command-palette-manager.svelte'; import type { CommandItem } from '$lib/services/command-palette-manager.svelte';
import { mdiClose } from '@mdi/js'; import { mdiClose } from '@mdi/js';
type Props = { type Props = {
item: CommandItem; item: CommandItem;
selected: boolean; selected: boolean;
onSelect: () => void; onSelect: () => void;
onRemove?: () => void; onRemove?: () => void;
}; };
const { item, selected, onRemove, onSelect }: Props = $props(); const { item, selected, onRemove, onSelect }: Props = $props();
const handleRemove = (event: MouseEvent) => { const handleRemove = (event: MouseEvent) => {
event.stopPropagation(); event.stopPropagation();
onRemove?.(); onRemove?.();
}; };
let ref = $state<HTMLElement | null>(null); let ref = $state<HTMLElement | null>(null);
$effect(() => { $effect(() => {
if (selected && ref) { if (selected && ref) {
ref.scrollIntoView({ block: 'nearest', inline: 'start', behavior: 'smooth' }); ref.scrollIntoView({ block: 'nearest', inline: 'start', behavior: 'smooth' });
} }
}); });
</script> </script>
<div bind:this={ref} class="p-1"> <div bind:this={ref} class="p-1">
<Button <Button
onclick={() => onSelect()} onclick={() => onSelect()}
fullWidth fullWidth
variant={selected ? 'outline' : 'ghost'} variant={selected ? 'outline' : 'ghost'}
color="secondary" color="secondary"
class="overflow-hidden border" class="overflow-hidden border"
> >
<div class="flex w-full place-items-center justify-between gap-2"> <div class="flex w-full place-items-center justify-between gap-2">
<div class="flex min-w-0 place-items-center gap-2"> <div class="flex min-w-0 place-items-center gap-2">
<Icon icon={item.icon} size="2rem" class={item.iconClass} /> <Icon icon={item.icon} size="2rem" class={item.iconClass} />
<div class="flex min-w-0 flex-col"> <div class="flex min-w-0 flex-col">
<div class="flex place-items-center gap-1"> <div class="flex place-items-center gap-1">
<Text fontWeight="bold">{item.title}</Text> <Text fontWeight="bold">{item.title}</Text>
</div> </div>
{#if item.description} {#if item.description}
<Text <Text
size="small" size="small"
class="overflow-hidden text-ellipsis whitespace-nowrap" class="overflow-hidden text-ellipsis whitespace-nowrap"
color={selected ? undefined : 'muted'}>{item.description}</Text color={selected ? undefined : 'muted'}>{item.description}</Text
> >
{/if} {/if}
</div> </div>
</div> </div>
{#if onRemove} {#if onRemove}
<IconButton <IconButton
size="small" size="small"
onclick={handleRemove} onclick={handleRemove}
icon={mdiClose} icon={mdiClose}
shape="round" shape="round"
variant="ghost" variant="ghost"
color="secondary" color="secondary"
aria-label="Remove" aria-label="Remove"
/> />
{:else} {:else}
<span class="shrink-0">[{item.type}]</span> <span class="shrink-0">[{item.type}]</span>
{/if} {/if}
</div> </div>
</Button> </Button>
</div> </div>
@@ -1,57 +1,57 @@
<script lang="ts"> <script lang="ts">
import Button from '$lib/components/Button/Button.svelte'; import Button from '$lib/components/Button/Button.svelte';
import Modal from '$lib/components/Modal/Modal.svelte'; import Modal from '$lib/components/Modal/Modal.svelte';
import ModalBody from '$lib/components/Modal/ModalBody.svelte'; import ModalBody from '$lib/components/Modal/ModalBody.svelte';
import ModalFooter from '$lib/components/Modal/ModalFooter.svelte'; import ModalFooter from '$lib/components/Modal/ModalFooter.svelte';
import HStack from '$lib/components/Stack/HStack.svelte'; import HStack from '$lib/components/Stack/HStack.svelte';
import type { Color } from '$lib/types.js'; import type { Color } from '$lib/types.js';
import { t } from '$lib/services/translation.svelte.js'; import { t } from '$lib/services/translation.svelte.js';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
interface Props { interface Props {
title?: string; title?: string;
icon?: string | boolean; icon?: string | boolean;
prompt?: string; prompt?: string;
confirmText?: string; confirmText?: string;
confirmColor?: Color; confirmColor?: Color;
disabled?: boolean; disabled?: boolean;
size?: 'small' | 'medium'; size?: 'small' | 'medium';
onClose: (confirmed: boolean) => void; onClose: (confirmed: boolean) => void;
promptSnippet?: Snippet; promptSnippet?: Snippet;
} }
let { let {
title = t('confirm'), title = t('confirm'),
icon, icon,
prompt = t('prompt_default'), prompt = t('prompt_default'),
confirmText = t('confirm'), confirmText = t('confirm'),
confirmColor = 'danger', confirmColor = 'danger',
disabled = false, disabled = false,
size = 'small', size = 'small',
onClose, onClose,
promptSnippet, promptSnippet,
}: Props = $props(); }: Props = $props();
const handleConfirm = () => { const handleConfirm = () => {
onClose(true); onClose(true);
}; };
</script> </script>
<Modal {title} onClose={() => onClose(false)} {size} {icon}> <Modal {title} onClose={() => onClose(false)} {size} {icon}>
<ModalBody> <ModalBody>
{#if promptSnippet}{@render promptSnippet()}{:else} {#if promptSnippet}{@render promptSnippet()}{:else}
<p>{prompt}</p> <p>{prompt}</p>
{/if} {/if}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<HStack fullWidth> <HStack fullWidth>
<Button shape="round" color="secondary" fullWidth onclick={() => onClose(false)}> <Button shape="round" color="secondary" fullWidth onclick={() => onClose(false)}>
{t('cancel')} {t('cancel')}
</Button> </Button>
<Button shape="round" color={confirmColor} fullWidth onclick={handleConfirm} {disabled}> <Button shape="round" color={confirmColor} fullWidth onclick={handleConfirm} {disabled}>
{confirmText} {confirmText}
</Button> </Button>
</HStack> </HStack>
</ModalFooter> </ModalFooter>
</Modal> </Modal>
+25 -25
View File
@@ -1,33 +1,33 @@
<script lang="ts"> <script lang="ts">
import type { Size } from '$lib/types.js'; import type { Size } from '$lib/types.js';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { tv } from 'tailwind-variants'; import { tv } from 'tailwind-variants';
type Props = { type Props = {
size?: Size | 'full'; size?: Size | 'full';
class?: string; class?: string;
center?: boolean; center?: boolean;
children?: Snippet; children?: Snippet;
}; };
const { center, class: className, size = 'full', children }: Props = $props(); const { center, class: className, size = 'full', children }: Props = $props();
const styles = tv({ const styles = tv({
base: '', base: '',
variants: { variants: {
size: { size: {
tiny: 'max-w-lg', tiny: 'max-w-lg',
small: 'max-w-(--breakpoint-sm)', small: 'max-w-(--breakpoint-sm)',
medium: 'max-w-(--breakpoint-md)', medium: 'max-w-(--breakpoint-md)',
large: 'max-w-(--breakpoint-lg)', large: 'max-w-(--breakpoint-lg)',
giant: 'max-w-(--breakpoint-xl)', giant: 'max-w-(--breakpoint-xl)',
full: 'w-full', full: 'w-full',
}, },
}, },
}); });
</script> </script>
<div class={cleanClass(styles({ size }), center && 'mx-auto w-full', className)}> <div class={cleanClass(styles({ size }), center && 'mx-auto w-full', className)}>
{@render children?.()} {@render children?.()}
</div> </div>
+21 -21
View File
@@ -1,31 +1,31 @@
<script lang="ts"> <script lang="ts">
import { setFieldContext } from '$lib/common/context.svelte.js'; import { setFieldContext } from '$lib/common/context.svelte.js';
import { withChildrenSnippets } from '$lib/common/use-child.svelte.js'; import { withChildrenSnippets } from '$lib/common/use-child.svelte.js';
import { ChildKey } from '$lib/constants.js'; import { ChildKey } from '$lib/constants.js';
import type { FieldContext } from '$lib/types.js'; import type { FieldContext } from '$lib/types.js';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import { type Snippet } from 'svelte'; import { type Snippet } from 'svelte';
type Props = FieldContext & { type Props = FieldContext & {
class?: string; class?: string;
children: Snippet; children: Snippet;
}; };
const { class: className, children, ...props }: Props = $props(); const { class: className, children, ...props }: Props = $props();
const state = $state(props); const state = $state(props);
setFieldContext(state); setFieldContext(state);
const { getChildren: getChildSnippet } = withChildrenSnippets(ChildKey.Field); const { getChildren: getChildSnippet } = withChildrenSnippets(ChildKey.Field);
const helperTextChildren = $derived(getChildSnippet(ChildKey.HelperText)); const helperTextChildren = $derived(getChildSnippet(ChildKey.HelperText));
</script> </script>
<div class={cleanClass('w-full', className)}> <div class={cleanClass('w-full', className)}>
{@render children()} {@render children()}
{#if helperTextChildren} {#if helperTextChildren}
<div class={cleanClass('pt-1', helperTextChildren.class)}> <div class={cleanClass('pt-1', helperTextChildren.class)}>
{@render helperTextChildren.snippet()} {@render helperTextChildren.snippet()}
</div> </div>
{/if} {/if}
</div> </div>
@@ -1,16 +1,16 @@
<script lang="ts"> <script lang="ts">
import { getBytesWithUnit } from '$lib/utilities/byte-units.js'; import { getBytesWithUnit } from '$lib/utilities/byte-units.js';
type Props = { type Props = {
bytes: number; bytes: number;
precision?: number; precision?: number;
variant?: 'short' | 'narrow'; variant?: 'short' | 'narrow';
}; };
const { bytes, precision, variant = 'short' }: Props = $props(); const { bytes, precision, variant = 'short' }: Props = $props();
const [value, unit] = $derived(getBytesWithUnit(bytes, precision)); const [value, unit] = $derived(getBytesWithUnit(bytes, precision));
const separator = $derived(variant === 'narrow' ? '' : ' '); const separator = $derived(variant === 'narrow' ? '' : ' ');
</script> </script>
<span>{value}{separator}{unit}</span> <span>{value}{separator}{unit}</span>
+41 -47
View File
@@ -1,57 +1,51 @@
<script lang="ts"> <script lang="ts">
import type { import type { FontWeight, HeadingColor, HeadingSize, HeadingTag, TextVariant } from '$lib/types.js';
FontWeight, import { cleanClass } from '$lib/utils.js';
HeadingColor, import Text from '$lib/internal/Text.svelte';
HeadingSize, import type { Snippet } from 'svelte';
HeadingTag, import type { HTMLAttributes } from 'svelte/elements';
TextVariant, import { tv } from 'tailwind-variants';
} from '$lib/types.js';
import { cleanClass } from '$lib/utils.js';
import Text from '$lib/internal/Text.svelte';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { tv } from 'tailwind-variants';
type Props = { type Props = {
/** /**
* The HTML element type. * The HTML element type.
*/ */
tag?: HeadingTag; tag?: HeadingTag;
size?: HeadingSize; size?: HeadingSize;
color?: HeadingColor; color?: HeadingColor;
fontWeight?: FontWeight; fontWeight?: FontWeight;
variant?: TextVariant; variant?: TextVariant;
class?: string; class?: string;
children: Snippet; children: Snippet;
} & HTMLAttributes<HTMLElement>; } & HTMLAttributes<HTMLElement>;
const { const {
tag = 'p', tag = 'p',
size = 'medium', size = 'medium',
fontWeight = 'semi-bold', fontWeight = 'semi-bold',
class: className, class: className,
children, children,
...restProps ...restProps
}: Props = $props(); }: Props = $props();
const styles = tv({ const styles = tv({
base: 'leading-none tracking-tight', base: 'leading-none tracking-tight',
variants: { variants: {
size: { size: {
tiny: 'text-lg', tiny: 'text-lg',
small: 'text-xl', small: 'text-xl',
medium: 'text-2xl', medium: 'text-2xl',
large: 'text-3xl', large: 'text-3xl',
giant: 'text-4xl', giant: 'text-4xl',
title: 'text-5xl', title: 'text-5xl',
}, },
}, },
}); });
const classList = $derived(cleanClass(styles({ size }), className)); const classList = $derived(cleanClass(styles({ size }), className));
</script> </script>
<Text {tag} {fontWeight} class={classList} {...restProps}> <Text {tag} {fontWeight} class={classList} {...restProps}>
{@render children()} {@render children()}
</Text> </Text>
+17 -17
View File
@@ -1,24 +1,24 @@
<script lang="ts"> <script lang="ts">
import Text from '$lib/components/Text/Text.svelte'; import Text from '$lib/components/Text/Text.svelte';
import { ChildKey } from '$lib/constants.js'; import { ChildKey } from '$lib/constants.js';
import Child from '$lib/internal/Child.svelte'; import Child from '$lib/internal/Child.svelte';
import type { TextColor } from '$lib/types.js'; import type { TextColor } from '$lib/types.js';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
type Props = { type Props = {
color?: TextColor; color?: TextColor;
class?: string; class?: string;
children?: Snippet; children?: Snippet;
}; };
let { class: className, children, color = 'muted' }: Props = $props(); let { class: className, children, color = 'muted' }: Props = $props();
</script> </script>
<Child for={ChildKey.Field} as={ChildKey.HelperText}> <Child for={ChildKey.Field} as={ChildKey.HelperText}>
<div class={cleanClass(className)}> <div class={cleanClass(className)}>
<Text {color} size="small"> <Text {color} size="small">
{@render children?.()} {@render children?.()}
</Text> </Text>
</div> </div>
</Child> </Child>
+37 -42
View File
@@ -1,52 +1,47 @@
<script lang="ts"> <script lang="ts">
import type { IconProps } from '$lib/types.js'; import type { IconProps } from '$lib/types.js';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
const { const {
size = '1em', size = '1em',
viewBox = '0 0 24 24', viewBox = '0 0 24 24',
class: className = '', class: className = '',
flipped = false, flipped = false,
flopped = false, flopped = false,
spin = false, spin = false,
strokeColor = 'transparent', strokeColor = 'transparent',
strokeWidth = 2, strokeWidth = 2,
role = 'img', role = 'img',
title, title,
icon, icon,
color = 'currentColor', color = 'currentColor',
description, description,
...restProps ...restProps
}: IconProps & HTMLAttributes<EventTarget> = $props(); }: IconProps & HTMLAttributes<EventTarget> = $props();
</script> </script>
<svg <svg
width={size} width={size}
height={size} height={size}
{viewBox} {viewBox}
class={cleanClass( class={cleanClass(className, flipped && '-scale-x-100', flopped && 'rotate-180', spin && 'animate-spin')}
className, stroke={strokeColor}
flipped && '-scale-x-100', stroke-width={strokeWidth}
flopped && 'rotate-180', {role}
spin && 'animate-spin', {...restProps}
)}
stroke={strokeColor}
stroke-width={strokeWidth}
{role}
{...restProps}
> >
{#if title} {#if title}
<title>{title}</title> <title>{title}</title>
{/if} {/if}
{#if description} {#if description}
<desc>{description}</desc> <desc>{description}</desc>
{/if} {/if}
<path d={typeof icon === 'string' ? icon : icon.path} fill={color} /> <path d={typeof icon === 'string' ? icon : icon.path} fill={color} />
</svg> </svg>
<style> <style>
svg { svg {
transition: transform 0.2s ease; transition: transform 0.2s ease;
} }
</style> </style>
@@ -1,20 +1,13 @@
<script lang="ts"> <script lang="ts">
import Button from '$lib/internal/Button.svelte'; import Button from '$lib/internal/Button.svelte';
import Icon from '$lib/components/Icon/Icon.svelte'; import Icon from '$lib/components/Icon/Icon.svelte';
import type { IconButtonProps } from '$lib/types.js'; import type { IconButtonProps } from '$lib/types.js';
const { const { icon, flipped, flopped, title, 'aria-label': ariaLabel, ...buttonProps }: IconButtonProps = $props();
icon,
flipped,
flopped,
title,
'aria-label': ariaLabel,
...buttonProps
}: IconButtonProps = $props();
const buttonTitle = $derived(title || ariaLabel); const buttonTitle = $derived(title || ariaLabel);
</script> </script>
<Button icon {...buttonProps} title={buttonTitle} aria-label={ariaLabel}> <Button icon {...buttonProps} title={buttonTitle} aria-label={ariaLabel}>
<Icon {icon} {flipped} {flopped} size="60%" aria-hidden /> <Icon {icon} {flipped} {flopped} size="60%" aria-hidden />
</Button> </Button>
+149 -152
View File
@@ -1,171 +1,168 @@
<script lang="ts"> <script lang="ts">
import { getFieldContext } from '$lib/common/context.svelte.js'; import { getFieldContext } from '$lib/common/context.svelte.js';
import Icon from '$lib/components/Icon/Icon.svelte'; import Icon from '$lib/components/Icon/Icon.svelte';
import Label from '$lib/components/Label/Label.svelte'; import Label from '$lib/components/Label/Label.svelte';
import Text from '$lib/components/Text/Text.svelte'; import Text from '$lib/components/Text/Text.svelte';
import type { InputProps } from '$lib/types.js'; import type { InputProps } from '$lib/types.js';
import { cleanClass, generateId, isIconLike } from '$lib/utils.js'; import { cleanClass, generateId, isIconLike } from '$lib/utils.js';
import { tv } from 'tailwind-variants'; import { tv } from 'tailwind-variants';
let { let {
ref = $bindable(null), ref = $bindable(null),
containerRef = $bindable(null), containerRef = $bindable(null),
shape = 'semi-round', shape = 'semi-round',
size = 'medium', size = 'medium',
class: className, class: className,
value = $bindable<string>(), value = $bindable<string>(),
leadingIcon, leadingIcon,
trailingIcon, trailingIcon,
trailingText, trailingText,
inputSize, inputSize,
...restProps ...restProps
}: InputProps = $props(); }: InputProps = $props();
const { label, description, readOnly, required, invalid, disabled, ...labelProps } = const { label, description, readOnly, required, invalid, disabled, ...labelProps } = $derived(getFieldContext());
$derived(getFieldContext());
const iconStyles = tv({ const iconStyles = tv({
base: 'flex flex-shrink-0 items-center justify-center', base: 'flex flex-shrink-0 items-center justify-center',
variants: { variants: {
size: { size: {
tiny: 'w-6', tiny: 'w-6',
small: 'w-8', small: 'w-8',
medium: 'w-10', medium: 'w-10',
large: 'w-12', large: 'w-12',
giant: 'w-14', giant: 'w-14',
}, },
}, },
}); });
const containerStyles = tv({ const containerStyles = tv({
base: 'flex w-full items-center bg-gray-200 outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-400 dark:bg-gray-600 dark:disabled:bg-gray-800 dark:disabled:text-gray-200', base: 'flex w-full items-center bg-gray-200 outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-400 dark:bg-gray-600 dark:disabled:bg-gray-800 dark:disabled:text-gray-200',
variants: { variants: {
shape: { shape: {
rectangle: 'rounded-none', rectangle: 'rounded-none',
'semi-round': '', 'semi-round': '',
round: 'rounded-full', round: 'rounded-full',
}, },
roundedSize: { roundedSize: {
tiny: 'rounded-xl', tiny: 'rounded-xl',
small: 'rounded-xl', small: 'rounded-xl',
medium: 'rounded-2xl', medium: 'rounded-2xl',
large: 'rounded-2xl', large: 'rounded-2xl',
giant: 'rounded-2xl', giant: 'rounded-2xl',
}, },
invalid: { invalid: {
true: 'border-danger/80 border', true: 'border-danger/80 border',
false: '', false: '',
}, },
}, },
}); });
const inputStyles = tv({ const inputStyles = tv({
base: 'flex-1 bg-transparent py-3 outline-none disabled:cursor-not-allowed', base: 'flex-1 bg-transparent py-3 outline-none disabled:cursor-not-allowed',
variants: { variants: {
textSize: { textSize: {
tiny: 'text-xs', tiny: 'text-xs',
small: 'text-sm', small: 'text-sm',
medium: 'text-base', medium: 'text-base',
large: 'text-lg', large: 'text-lg',
giant: 'text-xl', giant: 'text-xl',
}, },
leadingPadding: { leadingPadding: {
base: 'pl-4', base: 'pl-4',
icon: 'pl-0', icon: 'pl-0',
}, },
trailingPadding: { trailingPadding: {
base: 'pr-4', base: 'pr-4',
icon: 'pr-0', icon: 'pr-0',
}, },
}, },
}); });
const trailingTextStyles = tv({ const trailingTextStyles = tv({
variants: { variants: {
padding: { padding: {
base: 'px-4', base: 'px-4',
icon: 'pl-4', icon: 'pl-4',
}, },
}, },
}); });
const id = generateId(); const id = generateId();
const inputId = `input-${id}`; const inputId = `input-${id}`;
const labelId = `label-${id}`; const labelId = `label-${id}`;
const descriptionId = $derived(description ? `description-${id}` : undefined); const descriptionId = $derived(description ? `description-${id}` : undefined);
</script> </script>
<div class="flex w-full flex-col gap-1" bind:this={containerRef}> <div class="flex w-full flex-col gap-1" bind:this={containerRef}>
{#if label} {#if label}
<Label id={labelId} for={inputId} {label} {...labelProps} /> <Label id={labelId} for={inputId} {label} {...labelProps} />
{/if} {/if}
{#if description} {#if description}
<Text color="secondary" size="small" id={descriptionId}>{description}</Text> <Text color="secondary" size="small" id={descriptionId}>{description}</Text>
{/if} {/if}
<div <div
class={cleanClass( class={cleanClass(
containerStyles({ containerStyles({
shape, shape,
roundedSize: shape === 'semi-round' ? size : undefined, roundedSize: shape === 'semi-round' ? size : undefined,
invalid, invalid,
}), }),
className, className,
)} )}
> >
{#if leadingIcon} {#if leadingIcon}
<div tabindex="-1" class={iconStyles({ size })}> <div tabindex="-1" class={iconStyles({ size })}>
{#if isIconLike(leadingIcon)} {#if isIconLike(leadingIcon)}
<Icon size="60%" icon={leadingIcon} /> <Icon size="60%" icon={leadingIcon} />
{:else} {:else}
{@render leadingIcon()} {@render leadingIcon()}
{/if} {/if}
</div> </div>
{/if} {/if}
<input <input
id={inputId} id={inputId}
aria-labelledby={label && labelId} aria-labelledby={label && labelId}
{required} {required}
aria-required={required} aria-required={required}
{disabled} {disabled}
aria-disabled={disabled} aria-disabled={disabled}
aria-describedby={descriptionId} aria-describedby={descriptionId}
readonly={readOnly} readonly={readOnly}
size={inputSize} size={inputSize}
aria-readonly={readOnly} aria-readonly={readOnly}
class={inputStyles({ class={inputStyles({
textSize: size, textSize: size,
leadingPadding: leadingIcon ? 'icon' : 'base', leadingPadding: leadingIcon ? 'icon' : 'base',
trailingPadding: trailingIcon || trailingText ? 'icon' : 'base', trailingPadding: trailingIcon || trailingText ? 'icon' : 'base',
})} })}
bind:this={ref} bind:this={ref}
bind:value bind:value
{...restProps} {...restProps}
/> />
{#if trailingText} {#if trailingText}
<Text <Text {size} color="muted" class={trailingTextStyles({ padding: trailingIcon ? 'icon' : 'base' })}
{size} >{trailingText}</Text
color="muted" >
class={trailingTextStyles({ padding: trailingIcon ? 'icon' : 'base' })}>{trailingText}</Text {/if}
>
{/if}
{#if trailingIcon} {#if trailingIcon}
<div tabindex="-1" class={iconStyles({ size })}> <div tabindex="-1" class={iconStyles({ size })}>
{#if isIconLike(trailingIcon)} {#if isIconLike(trailingIcon)}
<Icon size="60%" icon={trailingIcon} /> <Icon size="60%" icon={trailingIcon} />
{:else} {:else}
{@render trailingIcon()} {@render trailingIcon()}
{/if} {/if}
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
<style> <style>
input::-ms-reveal { input::-ms-reveal {
display: none; display: none;
} }
</style> </style>
+25 -25
View File
@@ -1,33 +1,33 @@
<script lang="ts"> <script lang="ts">
import type { Color, Size } from '$lib/types.js'; import type { Color, Size } from '$lib/types.js';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
import { tv } from 'tailwind-variants'; import { tv } from 'tailwind-variants';
type Props = { type Props = {
size?: Size; size?: Size;
color?: Color; color?: Color;
class?: string; class?: string;
children?: Snippet; children?: Snippet;
} & HTMLAttributes<HTMLElement>; } & HTMLAttributes<HTMLElement>;
const { class: className, size = 'small', children, ...restProps }: Props = $props(); const { class: className, size = 'small', children, ...restProps }: Props = $props();
const styles = tv({ const styles = tv({
base: 'bg-subtle text-dark flex flex-col rounded-md border border-b-2 px-1 font-mono shadow', base: 'bg-subtle text-dark flex flex-col rounded-md border border-b-2 px-1 font-mono shadow',
variants: { variants: {
size: { size: {
tiny: 'text-xs', tiny: 'text-xs',
small: 'text-sm', small: 'text-sm',
medium: 'text-base', medium: 'text-base',
large: 'text-lg', large: 'text-lg',
giant: 'text-xl', giant: 'text-xl',
}, },
}, },
}); });
</script> </script>
<kbd class={cleanClass(styles({ size }), className)} {...restProps}> <kbd class={cleanClass(styles({ size }), className)} {...restProps}>
{@render children?.()} {@render children?.()}
</kbd> </kbd>
+6 -6
View File
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { styles } from '$lib/styles.js'; import { styles } from '$lib/styles.js';
import type { LabelProps } from '$lib/types.js'; import type { LabelProps } from '$lib/types.js';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
const { label, size, color, class: className, children, ...restProps }: LabelProps = $props(); const { label, size, color, class: className, children, ...restProps }: LabelProps = $props();
</script> </script>
<label class={cleanClass(styles.label({ size, color }), className)} {...restProps}> <label class={cleanClass(styles.label({ size, color }), className)} {...restProps}>
{#if label}{label}{/if} {#if label}{label}{/if}
{@render children?.()} {@render children?.()}
</label> </label>
+18 -25
View File
@@ -1,33 +1,26 @@
<script lang="ts"> <script lang="ts">
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { HTMLAnchorAttributes } from 'svelte/elements'; import type { HTMLAnchorAttributes } from 'svelte/elements';
type Props = { type Props = {
class?: string; class?: string;
children: Snippet; children: Snippet;
href: string; href: string;
underline?: boolean; underline?: boolean;
external?: boolean; external?: boolean;
} & HTMLAnchorAttributes; } & HTMLAnchorAttributes;
const { const { href, class: className, underline = true, external, children, ...restProps }: Props = $props();
href,
class: className,
underline = true,
external,
children,
...restProps
}: Props = $props();
</script> </script>
<a <a
{href} {href}
draggable="false" draggable="false"
class={cleanClass(underline && 'underline', className)} class={cleanClass(underline && 'underline', className)}
target={external ? '_blank' : undefined} target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined} rel={external ? 'noopener noreferrer' : undefined}
{...restProps} {...restProps}
> >
{@render children()} {@render children()}
</a> </a>
@@ -1,54 +1,54 @@
<script lang="ts"> <script lang="ts">
import type { Color, Size } from '$lib/types.js'; import type { Color, Size } from '$lib/types.js';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import { tv } from 'tailwind-variants'; import { tv } from 'tailwind-variants';
interface Props { interface Props {
size?: Size; size?: Size;
color?: Color; color?: Color;
class?: string; class?: string;
} }
let { size = 'medium', color = 'primary', class: className }: Props = $props(); let { size = 'medium', color = 'primary', class: className }: Props = $props();
const styles = tv({ const styles = tv({
base: 'fill-primary animate-spin text-gray-400 dark:text-gray-600', base: 'fill-primary animate-spin text-gray-400 dark:text-gray-600',
variants: { variants: {
size: { size: {
tiny: 'h-3', tiny: 'h-3',
small: 'h-4', small: 'h-4',
medium: 'h-5', medium: 'h-5',
large: 'h-6', large: 'h-6',
giant: 'h-12', giant: 'h-12',
}, },
color: { color: {
primary: 'fill-primary', primary: 'fill-primary',
secondary: 'fill-dark', secondary: 'fill-dark',
success: 'fill-success', success: 'fill-success',
danger: 'fill-danger', danger: 'fill-danger',
warning: 'fill-warning', warning: 'fill-warning',
info: 'fill-info', info: 'fill-info',
}, },
}, },
}); });
</script> </script>
<div> <div>
<svg <svg
role="status" role="status"
class={cleanClass(styles({ color, size }), className)} class={cleanClass(styles({ color, size }), className)}
viewBox="0 0 100 101" viewBox="0 0 100 101"
fill="none" fill="none"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
data-testid="loading-spinner" data-testid="loading-spinner"
> >
<path <path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z" d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor" fill="currentColor"
/> />
<path <path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z" d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill" fill="currentFill"
/> />
</svg> </svg>
</div> </div>
+53 -53
View File
@@ -1,66 +1,66 @@
<script lang="ts"> <script lang="ts">
import futoDark from '$lib/assets/immich-logo-futo-dark.svg'; import futoDark from '$lib/assets/immich-logo-futo-dark.svg';
import futoLight from '$lib/assets/immich-logo-futo-light.svg'; import futoLight from '$lib/assets/immich-logo-futo-light.svg';
import inlineDark from '$lib/assets/immich-logo-inline-dark.svg'; import inlineDark from '$lib/assets/immich-logo-inline-dark.svg';
import inlineLight from '$lib/assets/immich-logo-inline-light.svg'; import inlineLight from '$lib/assets/immich-logo-inline-light.svg';
import stackedDark from '$lib/assets/immich-logo-stacked-dark.svg'; import stackedDark from '$lib/assets/immich-logo-stacked-dark.svg';
import stackedLight from '$lib/assets/immich-logo-stacked-light.svg'; import stackedLight from '$lib/assets/immich-logo-stacked-light.svg';
import icon from '$lib/assets/immich-logo.svg'; import icon from '$lib/assets/immich-logo.svg';
import { theme } from '$lib/services/theme.svelte.js'; import { theme } from '$lib/services/theme.svelte.js';
import { Theme, type Size } from '$lib/types.js'; import { Theme, type Size } from '$lib/types.js';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import { tv } from 'tailwind-variants'; import { tv } from 'tailwind-variants';
type Props = { type Props = {
size?: Size | 'landing'; size?: Size | 'landing';
variant?: 'stacked' | 'inline' | 'logo' | 'icon' | 'stacked-futo'; variant?: 'stacked' | 'inline' | 'logo' | 'icon' | 'stacked-futo';
class?: string; class?: string;
}; };
const { variant = 'logo', size = 'medium', class: className }: Props = $props(); const { variant = 'logo', size = 'medium', class: className }: Props = $props();
const getUrl = (variant: Props['variant']): string => { const getUrl = (variant: Props['variant']): string => {
switch (variant) { switch (variant) {
case 'stacked': { case 'stacked': {
return theme.value === Theme.Light ? stackedLight : stackedDark; return theme.value === Theme.Light ? stackedLight : stackedDark;
} }
case 'inline': { case 'inline': {
return theme.value === Theme.Light ? inlineLight : inlineDark; return theme.value === Theme.Light ? inlineLight : inlineDark;
} }
case 'stacked-futo': { case 'stacked-futo': {
return theme.value === Theme.Light ? futoLight : futoDark; return theme.value === Theme.Light ? futoLight : futoDark;
} }
default: { default: {
return icon; return icon;
} }
} }
}; };
const styles = tv({ const styles = tv({
variants: { variants: {
size: { size: {
tiny: 'h-8', tiny: 'h-8',
small: 'h-10', small: 'h-10',
medium: 'h-12', medium: 'h-12',
large: 'h-16', large: 'h-16',
giant: 'h-24', giant: 'h-24',
landing: 'h-64', landing: 'h-64',
}, },
variant: { variant: {
stacked: '', stacked: '',
inline: '', inline: '',
'stacked-futo': '', 'stacked-futo': '',
logo: 'bg-light aspect-square rounded-full shadow-lg', logo: 'bg-light aspect-square rounded-full shadow-lg',
icon: 'aspect-square', icon: 'aspect-square',
}, },
}, },
}); });
const src = $derived(getUrl(variant)); const src = $derived(getUrl(variant));
</script> </script>
<img {src} class={cleanClass(styles({ size, variant }), className)} alt="Immich logo" /> <img {src} class={cleanClass(styles({ size, variant }), className)} alt="Immich logo" />
+110 -114
View File
@@ -1,131 +1,127 @@
<script lang="ts"> <script lang="ts">
import { withChildrenSnippets } from '$lib/common/use-child.svelte.js'; import { withChildrenSnippets } from '$lib/common/use-child.svelte.js';
import Card from '$lib/components/Card/Card.svelte'; import Card from '$lib/components/Card/Card.svelte';
import CardBody from '$lib/components/Card/CardBody.svelte'; import CardBody from '$lib/components/Card/CardBody.svelte';
import CardFooter from '$lib/components/Card/CardFooter.svelte'; import CardFooter from '$lib/components/Card/CardFooter.svelte';
import CardHeader from '$lib/components/Card/CardHeader.svelte'; import CardHeader from '$lib/components/Card/CardHeader.svelte';
import CardTitle from '$lib/components/Card/CardTitle.svelte'; import CardTitle from '$lib/components/Card/CardTitle.svelte';
import CloseButton from '$lib/components/CloseButton/CloseButton.svelte'; import CloseButton from '$lib/components/CloseButton/CloseButton.svelte';
import Icon from '$lib/components/Icon/Icon.svelte'; import Icon from '$lib/components/Icon/Icon.svelte';
import Logo from '$lib/components/Logo/Logo.svelte'; import Logo from '$lib/components/Logo/Logo.svelte';
import { ChildKey } from '$lib/constants.js'; import { ChildKey } from '$lib/constants.js';
import type { ModalSize } from '$lib/types.js'; import type { ModalSize } from '$lib/types.js';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import { Dialog } from 'bits-ui'; import { Dialog } from 'bits-ui';
import { tick, type Snippet } from 'svelte'; import { tick, type Snippet } from 'svelte';
import { tv } from 'tailwind-variants'; import { tv } from 'tailwind-variants';
type Props = { type Props = {
title?: string; title?: string;
icon?: string | boolean; icon?: string | boolean;
size?: ModalSize; size?: ModalSize;
class?: string; class?: string;
expandable?: boolean; expandable?: boolean;
closeOnEsc?: boolean; closeOnEsc?: boolean;
closeOnBackdropClick?: boolean; closeOnBackdropClick?: boolean;
children: Snippet; children: Snippet;
onClose?: () => void; onClose?: () => void;
}; };
let { let {
size = 'medium', size = 'medium',
onClose, onClose,
icon = true, icon = true,
title, title,
class: className, class: className,
closeOnEsc = true, closeOnEsc = true,
closeOnBackdropClick = false, closeOnBackdropClick = false,
children, children,
}: Props = $props(); }: Props = $props();
const modalStyles = tv({ const modalStyles = tv({
base: 'bg-light dark:bg-subtle border-subtle shadow-primary/20 flex rounded-none border shadow-sm sm:rounded-2xl dark:border-white/10', base: 'bg-light dark:bg-subtle border-subtle shadow-primary/20 flex rounded-none border shadow-sm sm:rounded-2xl dark:border-white/10',
variants: { variants: {
size: { size: {
tiny: 'h-full sm:h-min md:max-w-sm', tiny: 'h-full sm:h-min md:max-w-sm',
small: 'h-full sm:h-min md:max-w-md', small: 'h-full sm:h-min md:max-w-md',
medium: 'h-full sm:h-min md:max-w-(--breakpoint-sm)', medium: 'h-full sm:h-min md:max-w-(--breakpoint-sm)',
large: 'h-full sm:h-min md:max-w-(--breakpoint-md)', large: 'h-full sm:h-min md:max-w-(--breakpoint-md)',
giant: 'h-full sm:h-min md:max-w-(--breakpoint-lg)', giant: 'h-full sm:h-min md:max-w-(--breakpoint-lg)',
full: 'h-full w-full', full: 'h-full w-full',
}, },
}, },
}); });
const { getChildren: getChildSnippet } = withChildrenSnippets(ChildKey.Modal); const { getChildren: getChildSnippet } = withChildrenSnippets(ChildKey.Modal);
const headerChildren = $derived(getChildSnippet(ChildKey.ModalHeader)); const headerChildren = $derived(getChildSnippet(ChildKey.ModalHeader));
const bodyChildren = $derived(getChildSnippet(ChildKey.ModalBody)); const bodyChildren = $derived(getChildSnippet(ChildKey.ModalBody));
const footerChildren = $derived(getChildSnippet(ChildKey.ModalFooter)); const footerChildren = $derived(getChildSnippet(ChildKey.ModalFooter));
const handleClose = async () => { const handleClose = async () => {
// wait for bits-ui to complete its event cycle // wait for bits-ui to complete its event cycle
await tick(); await tick();
await new Promise((resolve) => setTimeout(resolve, 10)); await new Promise((resolve) => setTimeout(resolve, 10));
onClose?.(); onClose?.();
}; };
let cardRef = $state<HTMLElement | null>(null); let cardRef = $state<HTMLElement | null>(null);
const handleCloseOnClick = (event: Event) => { const handleCloseOnClick = (event: Event) => {
if (!closeOnBackdropClick || cardRef?.contains(event.target as Node)) { if (!closeOnBackdropClick || cardRef?.contains(event.target as Node)) {
return; return;
} }
onClose?.(); onClose?.();
}; };
</script> </script>
<Dialog.Root open={true}> <Dialog.Root open={true}>
<Dialog.Portal> <Dialog.Portal>
<Dialog.Overlay class="fixed start-0 top-0 flex h-dvh w-screen bg-black/30" /> <Dialog.Overlay class="fixed start-0 top-0 flex h-dvh w-screen bg-black/30" />
<Dialog.Content <Dialog.Content
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.stopPropagation(); e.stopPropagation();
e.preventDefault(); e.preventDefault();
if (closeOnEsc) { if (closeOnEsc) {
handleClose(); handleClose();
} }
} }
}} }}
onclick={handleCloseOnClick} onclick={handleCloseOnClick}
class={cleanClass( class={cleanClass('fixed start-0 top-0 flex h-dvh w-screen items-center justify-center overflow-hidden sm:p-4')}
'fixed start-0 top-0 flex h-dvh w-screen items-center justify-center overflow-hidden sm:p-4', >
)} <div class={cleanClass('flex h-full w-full flex-col items-center justify-center')}>
> <Card bind:ref={cardRef} class={cleanClass(modalStyles({ size }), className)}>
<div class={cleanClass('flex h-full w-full flex-col items-center justify-center')}> <CardHeader class="border-b border-gray-200 px-5 py-3 dark:border-white/10">
<Card bind:ref={cardRef} class={cleanClass(modalStyles({ size }), className)}> {#if headerChildren}
<CardHeader class="border-b border-gray-200 px-5 py-3 dark:border-white/10"> {@render headerChildren.snippet()}
{#if headerChildren} {:else if title}
{@render headerChildren.snippet()} <div class="flex items-center justify-between gap-2">
{:else if title} {#if typeof icon === 'string'}
<div class="flex items-center justify-between gap-2"> <Icon {icon} size="1.5rem" aria-hidden />
{#if typeof icon === 'string'} {:else if icon}
<Icon {icon} size="1.5rem" aria-hidden /> <Logo variant="icon" size="tiny" />
{:else if icon} {/if}
<Logo variant="icon" size="tiny" /> <CardTitle tag="p" class="text-dark/90 grow text-lg font-semibold">{title}</CardTitle>
{/if} <CloseButton class="-me-2" onclick={() => handleClose()} />
<CardTitle tag="p" class="text-dark/90 grow text-lg font-semibold" </div>
>{title}</CardTitle {/if}
> </CardHeader>
<CloseButton class="-me-2" onclick={() => handleClose()} />
</div>
{/if}
</CardHeader>
<CardBody class="grow px-5"> <CardBody class="grow px-5">
{@render bodyChildren?.snippet()} {@render bodyChildren?.snippet()}
</CardBody> </CardBody>
{#if footerChildren} {#if footerChildren}
<CardFooter class="border-t border-gray-200 dark:border-white/10"> <CardFooter class="border-t border-gray-200 dark:border-white/10">
{@render footerChildren.snippet()} {@render footerChildren.snippet()}
</CardFooter> </CardFooter>
{/if} {/if}
</Card> </Card>
</div> </div>
</Dialog.Content> </Dialog.Content>
</Dialog.Portal> </Dialog.Portal>
</Dialog.Root> </Dialog.Root>
{@render children?.()} {@render children?.()}
+8 -8
View File
@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { ChildKey } from '$lib/constants.js'; import { ChildKey } from '$lib/constants.js';
import Child from '$lib/internal/Child.svelte'; import Child from '$lib/internal/Child.svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
type Props = { type Props = {
children: Snippet; children: Snippet;
}; };
let { children }: Props = $props(); let { children }: Props = $props();
</script> </script>
<Child for={ChildKey.Modal} as={ChildKey.ModalBody}> <Child for={ChildKey.Modal} as={ChildKey.ModalBody}>
{@render children?.()} {@render children?.()}
</Child> </Child>
+8 -8
View File
@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { ChildKey } from '$lib/constants.js'; import { ChildKey } from '$lib/constants.js';
import Child from '$lib/internal/Child.svelte'; import Child from '$lib/internal/Child.svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
type Props = { type Props = {
children: Snippet; children: Snippet;
}; };
let { children }: Props = $props(); let { children }: Props = $props();
</script> </script>
<Child for={ChildKey.Modal} as={ChildKey.ModalFooter}> <Child for={ChildKey.Modal} as={ChildKey.ModalFooter}>
{@render children?.()} {@render children?.()}
</Child> </Child>
+8 -8
View File
@@ -1,15 +1,15 @@
<script lang="ts"> <script lang="ts">
import { ChildKey } from '$lib/constants.js'; import { ChildKey } from '$lib/constants.js';
import Child from '$lib/internal/Child.svelte'; import Child from '$lib/internal/Child.svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
type Props = { type Props = {
children: Snippet; children: Snippet;
}; };
let { children }: Props = $props(); let { children }: Props = $props();
</script> </script>
<Child for={ChildKey.Modal} as={ChildKey.ModalHeader}> <Child for={ChildKey.Modal} as={ChildKey.ModalHeader}>
{@render children?.()} {@render children?.()}
</Child> </Child>
@@ -1,14 +1,14 @@
<script lang="ts"> <script lang="ts">
import InternalSelect from '$lib/internal/Select.svelte'; import InternalSelect from '$lib/internal/Select.svelte';
import type { MultiSelectProps, SelectItem } from '$lib/types.js'; import type { MultiSelectProps, SelectItem } from '$lib/types.js';
type T = SelectItem; type T = SelectItem;
let { onChange, values = $bindable([]), ...restProps }: MultiSelectProps<T> = $props(); let { onChange, values = $bindable([]), ...restProps }: MultiSelectProps<T> = $props();
const handleChange = (items: T[]) => { const handleChange = (items: T[]) => {
onChange?.(items); onChange?.(items);
}; };
</script> </script>
<InternalSelect multiple bind:values onChange={handleChange} {...restProps} /> <InternalSelect multiple bind:values onChange={handleChange} {...restProps} />
+5 -5
View File
@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
type Props = { type Props = {
title: string; title: string;
}; };
let { title }: Props = $props(); let { title }: Props = $props();
</script> </script>
<div class="text-sm transition-all duration-200 md:text-sm"> <div class="text-sm transition-all duration-200 md:text-sm">
<p class="py-2 ps-4">{title}</p> <p class="py-2 ps-4">{title}</p>
</div> </div>
+59 -61
View File
@@ -1,73 +1,71 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import Icon from '$lib/components/Icon/Icon.svelte'; import Icon from '$lib/components/Icon/Icon.svelte';
import Link from '$lib/components/Link/Link.svelte'; import Link from '$lib/components/Link/Link.svelte';
import type { IconProps } from '$lib/types.js'; import type { IconProps } from '$lib/types.js';
import { tv } from 'tailwind-variants'; import { tv } from 'tailwind-variants';
type Props = { type Props = {
title: string; title: string;
href: string; href: string;
external?: boolean; external?: boolean;
active?: boolean; active?: boolean;
variant?: 'compact'; variant?: 'compact';
isActive?: () => boolean; isActive?: () => boolean;
icon?: string | IconProps; icon?: string | IconProps;
activeIcon?: string | IconProps; activeIcon?: string | IconProps;
}; };
const startsWithHref = () => page.url.pathname.startsWith(href); const startsWithHref = () => page.url.pathname.startsWith(href);
let { let {
href, href,
external, external,
isActive: isActiveOverride, isActive: isActiveOverride,
title, title,
variant, variant,
active: activeOverride, active: activeOverride,
icon, icon,
activeIcon, activeIcon,
}: Props = $props(); }: Props = $props();
const isActive = isActiveOverride ?? startsWithHref; const isActive = isActiveOverride ?? startsWithHref;
let active = $derived(activeOverride ?? isActive()); let active = $derived(activeOverride ?? isActive());
const iconProps = $derived(typeof icon === 'string' ? { icon } : icon); const iconProps = $derived(typeof icon === 'string' ? { icon } : icon);
const activeIconProps = $derived( const activeIconProps = $derived(typeof activeIcon === 'string' ? { icon: activeIcon } : activeIcon);
typeof activeIcon === 'string' ? { icon: activeIcon } : activeIcon,
);
const styles = tv({ const styles = tv({
base: 'hover:bg-subtle hover:text-primary flex w-full place-items-center gap-4 rounded-e-full px-5 transition-[padding] delay-100 duration-100 group-hover:sm:px-5', base: 'hover:bg-subtle hover:text-primary flex w-full place-items-center gap-4 rounded-e-full px-5 transition-[padding] delay-100 duration-100 group-hover:sm:px-5',
variants: { variants: {
active: { active: {
true: 'bg-primary/10 text-primary', true: 'bg-primary/10 text-primary',
false: '', false: '',
}, },
variant: { variant: {
default: 'py-3 ps-5', default: 'py-3 ps-5',
compact: 'py-2 ps-3', compact: 'py-2 ps-3',
}, },
}, },
}); });
</script> </script>
<Link <Link
{href} {href}
{external} {external}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
underline={false} underline={false}
class={styles({ active, variant: variant ?? 'default' })} class={styles({ active, variant: variant ?? 'default' })}
> >
<div class="flex w-full place-items-center gap-4 truncate overflow-hidden"> <div class="flex w-full place-items-center gap-4 truncate overflow-hidden">
{#if iconProps} {#if iconProps}
<Icon <Icon
size="1.375em" size="1.375em"
class="shrink-0" class="shrink-0"
aria-hidden={true} aria-hidden={true}
{...active && activeIconProps ? activeIconProps : iconProps} {...active && activeIconProps ? activeIconProps : iconProps}
/> />
{/if} {/if}
<span class="text-sm font-medium">{title}</span> <span class="text-sm font-medium">{title}</span>
</div> </div>
</Link> </Link>
@@ -1,38 +1,36 @@
<script lang="ts"> <script lang="ts">
import IconButton from '$lib/components/IconButton/IconButton.svelte'; import IconButton from '$lib/components/IconButton/IconButton.svelte';
import Input from '$lib/components/Input/Input.svelte'; import Input from '$lib/components/Input/Input.svelte';
import { t } from '$lib/services/translation.svelte.js'; import { t } from '$lib/services/translation.svelte.js';
import type { PasswordInputProps } from '$lib/types.js'; import type { PasswordInputProps } from '$lib/types.js';
import { mdiEyeOffOutline, mdiEyeOutline } from '@mdi/js'; import { mdiEyeOffOutline, mdiEyeOutline } from '@mdi/js';
let { let {
value = $bindable<string>(), value = $bindable<string>(),
translations, translations,
isVisible = $bindable<boolean>(false), isVisible = $bindable<boolean>(false),
color = 'secondary', color = 'secondary',
size, size,
...props ...props
}: PasswordInputProps = $props(); }: PasswordInputProps = $props();
let labelValue = $derived( let labelValue = $derived(isVisible ? t('hide_password', translations) : t('show_password', translations));
isVisible ? t('hide_password', translations) : t('show_password', translations),
);
</script> </script>
<Input bind:value {size} type={isVisible ? 'text' : 'password'} {color} {...props}> <Input bind:value {size} type={isVisible ? 'text' : 'password'} {color} {...props}>
{#snippet trailingIcon()} {#snippet trailingIcon()}
{#if value?.length > 0} {#if value?.length > 0}
<IconButton <IconButton
variant="ghost" variant="ghost"
shape="round" shape="round"
color="secondary" color="secondary"
{size} {size}
class="me-1" class="me-1"
icon={isVisible ? mdiEyeOffOutline : mdiEyeOutline} icon={isVisible ? mdiEyeOffOutline : mdiEyeOutline}
onclick={() => (isVisible = !isVisible)} onclick={() => (isVisible = !isVisible)}
title={labelValue} title={labelValue}
aria-label={labelValue} aria-label={labelValue}
/> />
{/if} {/if}
{/snippet} {/snippet}
</Input> </Input>
+49 -49
View File
@@ -1,67 +1,67 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate } from '$app/navigation'; import { afterNavigate } from '$app/navigation';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
type Props = { type Props = {
class?: string; class?: string;
children?: Snippet; children?: Snippet;
transition?: TransitionEvent; transition?: TransitionEvent;
ref?: HTMLDivElement; ref?: HTMLDivElement;
resetOnNavigate?: boolean; resetOnNavigate?: boolean;
}; };
let { resetOnNavigate = false, class: className, children, ref = $bindable() }: Props = $props(); let { resetOnNavigate = false, class: className, children, ref = $bindable() }: Props = $props();
afterNavigate(() => { afterNavigate(() => {
if (resetOnNavigate) { if (resetOnNavigate) {
ref?.scrollTo(0, 0); ref?.scrollTo(0, 0);
} }
}); });
</script> </script>
<div bind:this={ref} class={cleanClass('immich-scrollbar h-full w-full overflow-auto', className)}> <div bind:this={ref} class={cleanClass('immich-scrollbar h-full w-full overflow-auto', className)}>
{@render children?.()} {@render children?.()}
</div> </div>
<style> <style>
/* width */ /* width */
.immich-scrollbar::-webkit-scrollbar { .immich-scrollbar::-webkit-scrollbar {
width: 8px; width: 8px;
height: 8px; height: 8px;
visibility: hidden; visibility: hidden;
} }
/* Track */ /* Track */
.immich-scrollbar::-webkit-scrollbar-track { .immich-scrollbar::-webkit-scrollbar-track {
background: #f1f1f1; background: #f1f1f1;
border-radius: 16px; border-radius: 16px;
visibility: hidden; visibility: hidden;
} }
/* Handle */ /* Handle */
.immich-scrollbar::-webkit-scrollbar-thumb { .immich-scrollbar::-webkit-scrollbar-thumb {
background: rgba(85, 86, 87, 0.408); background: rgba(85, 86, 87, 0.408);
border-radius: 16px; border-radius: 16px;
visibility: hidden; visibility: hidden;
} }
/* Handle on hover */ /* Handle on hover */
.immich-scrollbar::-webkit-scrollbar-thumb:hover { .immich-scrollbar::-webkit-scrollbar-thumb:hover {
background: #4250afad; background: #4250afad;
border-radius: 16px; border-radius: 16px;
} }
/* /*
* Show scrollbar elements when hovering or actively scrolling * Show scrollbar elements when hovering or actively scrolling
* Applies to the main scrollbar, track, and thumb components * Applies to the main scrollbar, track, and thumb components
* Changes visibility from hidden to visible on user interaction * Changes visibility from hidden to visible on user interaction
*/ */
.immich-scrollbar:hover::-webkit-scrollbar, .immich-scrollbar:hover::-webkit-scrollbar,
.immich-scrollbar:active::-webkit-scrollbar, .immich-scrollbar:active::-webkit-scrollbar,
.immich-scrollbar:hover::-webkit-scrollbar-track, .immich-scrollbar:hover::-webkit-scrollbar-track,
.immich-scrollbar:active::-webkit-scrollbar-track, .immich-scrollbar:active::-webkit-scrollbar-track,
.immich-scrollbar:hover::-webkit-scrollbar-thumb, .immich-scrollbar:hover::-webkit-scrollbar-thumb,
.immich-scrollbar:active::-webkit-scrollbar-thumb { .immich-scrollbar:active::-webkit-scrollbar-thumb {
visibility: visible; visibility: visible;
} }
</style> </style>
+9 -9
View File
@@ -1,17 +1,17 @@
<script lang="ts"> <script lang="ts">
import InternalSelect from '$lib/internal/Select.svelte'; import InternalSelect from '$lib/internal/Select.svelte';
import type { SelectItem, SelectProps } from '$lib/types.js'; import type { SelectItem, SelectProps } from '$lib/types.js';
type T = SelectItem; type T = SelectItem;
let { onChange, value = $bindable(), ...restProps }: SelectProps<T> = $props(); let { onChange, value = $bindable(), ...restProps }: SelectProps<T> = $props();
let values = $derived(value ? [value] : []); let values = $derived(value ? [value] : []);
const handleChange = (items: T[]) => { const handleChange = (items: T[]) => {
value = items[0]; value = items[0];
onChange?.(value); onChange?.(value);
}; };
</script> </script>
<InternalSelect bind:values onChange={handleChange} {...restProps} /> <InternalSelect bind:values onChange={handleChange} {...restProps} />
+4 -4
View File
@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import Stack from '$lib/components/Stack/Stack.svelte'; import Stack from '$lib/components/Stack/Stack.svelte';
import type { HStackProps } from '$lib/types.js'; import type { HStackProps } from '$lib/types.js';
const { class: className, children, ...props }: HStackProps = $props(); const { class: className, children, ...props }: HStackProps = $props();
</script> </script>
<Stack direction="row" align="center" class={className} {...props}> <Stack direction="row" align="center" class={className} {...props}>
{@render children()} {@render children()}
</Stack> </Stack>
+62 -62
View File
@@ -1,70 +1,70 @@
<script lang="ts"> <script lang="ts">
import type { StackProps } from '$lib/types.js'; import type { StackProps } from '$lib/types.js';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import { tv } from 'tailwind-variants'; import { tv } from 'tailwind-variants';
const { const {
align, align,
direction = 'column', direction = 'column',
wrap = false, wrap = false,
class: className, class: className,
fullWidth, fullWidth,
fullHeight, fullHeight,
gap = 2, gap = 2,
children, children,
}: StackProps = $props(); }: StackProps = $props();
const styles = tv({ const styles = tv({
base: 'flex gap-2', base: 'flex gap-2',
variants: { variants: {
direction: { direction: {
row: 'flex-row', row: 'flex-row',
column: 'flex-col', column: 'flex-col',
}, },
align: { align: {
start: 'items-start', start: 'items-start',
center: 'items-center', center: 'items-center',
end: 'items-end', end: 'items-end',
}, },
fullWidth: { fullWidth: {
true: 'w-full', true: 'w-full',
false: '', false: '',
}, },
fullHeight: { fullHeight: {
true: 'h-full', true: 'h-full',
false: '', false: '',
}, },
gap: { gap: {
0: 'gap-0', 0: 'gap-0',
1: 'gap-1', 1: 'gap-1',
2: 'gap-2', 2: 'gap-2',
3: 'gap-3', 3: 'gap-3',
4: 'gap-4', 4: 'gap-4',
5: 'gap-5', 5: 'gap-5',
6: 'gap-6', 6: 'gap-6',
7: 'gap-7', 7: 'gap-7',
8: 'gap-8', 8: 'gap-8',
}, },
wrap: { wrap: {
true: 'flex-wrap', true: 'flex-wrap',
false: '', false: '',
}, },
}, },
}); });
</script> </script>
<div <div
class={cleanClass( class={cleanClass(
styles({ styles({
align, align,
direction, direction,
gap, gap,
wrap, wrap,
fullHeight, fullHeight,
fullWidth, fullWidth,
}), }),
className, className,
)} )}
> >
{@render children()} {@render children()}
</div> </div>
+4 -4
View File
@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import Stack from '$lib/components/Stack/Stack.svelte'; import Stack from '$lib/components/Stack/Stack.svelte';
import type { VStackProps } from '$lib/types.js'; import type { VStackProps } from '$lib/types.js';
const { class: className, children, ...props }: VStackProps = $props(); const { class: className, children, ...props }: VStackProps = $props();
</script> </script>
<Stack direction="column" align="center" class={className} {...props}> <Stack direction="column" align="center" class={className} {...props}>
{@render children()} {@render children()}
</Stack> </Stack>
@@ -1,94 +1,94 @@
<script lang="ts"> <script lang="ts">
import Logo from '$lib/components/Logo/Logo.svelte'; import Logo from '$lib/components/Logo/Logo.svelte';
import Text from '$lib/components/Text/Text.svelte'; import Text from '$lib/components/Text/Text.svelte';
import type { Size } from '$lib/types.js'; import type { Size } from '$lib/types.js';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import { tv } from 'tailwind-variants'; import { tv } from 'tailwind-variants';
type Props = { type Props = {
effect?: 'hover' | 'always'; effect?: 'hover' | 'always';
text?: string; text?: string;
size?: Size; size?: Size;
children?: Snippet; children?: Snippet;
}; };
const { effect = 'hover', text = 'Supporter', size = 'medium', children }: Props = $props(); const { effect = 'hover', text = 'Supporter', size = 'medium', children }: Props = $props();
const iconSize: Record<Size, Size> = { const iconSize: Record<Size, Size> = {
tiny: 'tiny', tiny: 'tiny',
small: 'tiny', small: 'tiny',
medium: 'small', medium: 'small',
large: 'medium', large: 'medium',
giant: 'medium', giant: 'medium',
}; };
const containerStyles = tv({ const containerStyles = tv({
base: 'bg-secondary flex place-items-center gap-2 overflow-hidden rounded-lg transition-all', base: 'bg-secondary flex place-items-center gap-2 overflow-hidden rounded-lg transition-all',
variants: { variants: {
size: { size: {
tiny: 'px-2 py-1', tiny: 'px-2 py-1',
small: 'px-2 py-1', small: 'px-2 py-1',
medium: 'px-3 py-2', medium: 'px-3 py-2',
large: 'px-3 py-2', large: 'px-3 py-2',
giant: 'px-3 py-2', giant: 'px-3 py-2',
}, },
effect: { effect: {
hover: 'supporter-effect-hover border-dark/25 border', hover: 'supporter-effect-hover border-dark/25 border',
always: 'supporter-effect shadow', always: 'supporter-effect shadow',
}, },
}, },
}); });
</script> </script>
<div class={cleanClass(containerStyles({ effect, size }))}> <div class={cleanClass(containerStyles({ effect, size }))}>
{#if children} {#if children}
{@render children()} {@render children()}
{:else} {:else}
<Logo size={iconSize[size]} variant="icon" /> <Logo size={iconSize[size]} variant="icon" />
<Text fontWeight="normal" color="secondary" {size}>{text}</Text> <Text fontWeight="normal" color="secondary" {size}>{text}</Text>
{/if} {/if}
</div> </div>
<style> <style>
.supporter-effect, .supporter-effect,
.supporter-effect-hover:hover { .supporter-effect-hover:hover {
position: relative; position: relative;
background-clip: padding-box; background-clip: padding-box;
animation: gradient 10s ease infinite; animation: gradient 10s ease infinite;
z-index: 1; z-index: 1;
} }
.supporter-effect:after, .supporter-effect:after,
.supporter-effect-hover:hover:after { .supporter-effect-hover:hover:after {
position: absolute; position: absolute;
top: 0px; top: 0px;
bottom: 0px; bottom: 0px;
left: 0px; left: 0px;
right: 0px; right: 0px;
background: linear-gradient( background: linear-gradient(
to right, to right,
rgba(16, 132, 254, 0.15), rgba(16, 132, 254, 0.15),
rgba(229, 125, 175, 0.15), rgba(229, 125, 175, 0.15),
rgba(254, 36, 29, 0.15), rgba(254, 36, 29, 0.15),
rgba(255, 183, 0, 0.15), rgba(255, 183, 0, 0.15),
rgba(22, 193, 68, 0.15) rgba(22, 193, 68, 0.15)
); );
content: ''; content: '';
animation: gradient 10s ease infinite; animation: gradient 10s ease infinite;
background-size: 400% 400%; background-size: 400% 400%;
z-index: -1; z-index: -1;
} }
@keyframes gradient { @keyframes gradient {
0% { 0% {
background-position: 0% 50%; background-position: 0% 50%;
} }
50% { 50% {
background-position: 100% 50%; background-position: 100% 50%;
} }
100% { 100% {
background-position: 0% 50%; background-position: 0% 50%;
} }
} }
</style> </style>
+95 -96
View File
@@ -1,110 +1,109 @@
<script lang="ts"> <script lang="ts">
import { getFieldContext } from '$lib/common/context.svelte.js'; import { getFieldContext } from '$lib/common/context.svelte.js';
import Label from '$lib/components/Label/Label.svelte'; import Label from '$lib/components/Label/Label.svelte';
import Text from '$lib/components/Text/Text.svelte'; import Text from '$lib/components/Text/Text.svelte';
import type { Color } from '$lib/types.js'; import type { Color } from '$lib/types.js';
import { cleanClass, generateId } from '$lib/utils.js'; import { cleanClass, generateId } from '$lib/utils.js';
import { Switch, type WithoutChildrenOrChild } from 'bits-ui'; import { Switch, type WithoutChildrenOrChild } from 'bits-ui';
import { tv } from 'tailwind-variants'; import { tv } from 'tailwind-variants';
type Props = { type Props = {
checked?: boolean; checked?: boolean;
color?: Color; color?: Color;
disabled?: boolean; disabled?: boolean;
class?: string; class?: string;
} & WithoutChildrenOrChild<Switch.RootProps>; } & WithoutChildrenOrChild<Switch.RootProps>;
let { let {
id = generateId(), id = generateId(),
checked = $bindable(false), checked = $bindable(false),
ref = $bindable(null), ref = $bindable(null),
color = 'primary', color = 'primary',
class: className, class: className,
...restProps ...restProps
}: Props = $props(); }: Props = $props();
const { readOnly, required, disabled, label, description, ...labelProps } = const { readOnly, required, disabled, label, description, ...labelProps } = $derived(getFieldContext());
$derived(getFieldContext());
const enabled = $derived(checked && !disabled); const enabled = $derived(checked && !disabled);
const wrapper = tv({ const wrapper = tv({
base: 'relative flex flex-col justify-center', base: 'relative flex flex-col justify-center',
variants: { variants: {
disabled: { disabled: {
true: 'cursor-not-allowed opacity-38', true: 'cursor-not-allowed opacity-38',
false: 'cursor-pointer', false: 'cursor-pointer',
}, },
}, },
}); });
const bar = tv({ const bar = tv({
base: 'h-8 w-13 rounded-full border border-2', base: 'h-8 w-13 rounded-full border border-2',
variants: { variants: {
fillColor: { fillColor: {
default: 'border-gray-400 bg-gray-300 dark:border-gray-500 dark:bg-gray-400', default: 'border-gray-400 bg-gray-300 dark:border-gray-500 dark:bg-gray-400',
primary: 'bg-primary/50 dark:bg-primary border-transparent', primary: 'bg-primary/50 dark:bg-primary border-transparent',
secondary: 'bg-dark/50 border-transparent', secondary: 'bg-dark/50 border-transparent',
success: 'bg-success/50 border-transparent', success: 'bg-success/50 border-transparent',
danger: 'bg-danger/50 border-transparent', danger: 'bg-danger/50 border-transparent',
warning: 'bg-warning/50 border-transparent', warning: 'bg-warning/50 border-transparent',
info: 'bg-info/50 border-transparent', info: 'bg-info/50 border-transparent',
}, },
}, },
}); });
const dot = tv({ const dot = tv({
base: 'absolute h-4 w-4 origin-center rounded-full transition-transform duration-100', base: 'absolute h-4 w-4 origin-center rounded-full transition-transform duration-100',
variants: { variants: {
checked: { checked: {
true: 'translate-x-7 scale-150 rtl:-translate-x-7', true: 'translate-x-7 scale-150 rtl:-translate-x-7',
false: 'translate-x-2 rtl:-translate-x-2', false: 'translate-x-2 rtl:-translate-x-2',
}, },
fillColor: { fillColor: {
default: 'bg-gray-400 dark:bg-gray-500', default: 'bg-gray-400 dark:bg-gray-500',
primary: 'bg-[#4250af]', primary: 'bg-[#4250af]',
secondary: 'bg-dark', secondary: 'bg-dark',
success: 'bg-success', success: 'bg-success',
danger: 'bg-danger', danger: 'bg-danger',
warning: 'bg-warning', warning: 'bg-warning',
info: 'bg-info', info: 'bg-info',
}, },
}, },
}); });
const inputId = `input-${id}`; const inputId = `input-${id}`;
const labelId = `label-${id}`; const labelId = `label-${id}`;
const descriptionId = $derived(description ? `description-${id}` : undefined); const descriptionId = $derived(description ? `description-${id}` : undefined);
</script> </script>
<Switch.Root <Switch.Root
bind:checked bind:checked
bind:ref bind:ref
id={inputId} id={inputId}
disabled={disabled || readOnly} disabled={disabled || readOnly}
{required} {required}
class={cleanClass(label && 'w-full', className)} class={cleanClass(label && 'w-full', className)}
aria-readonly={readOnly} aria-readonly={readOnly}
aria-labelledby={labelId} aria-labelledby={labelId}
aria-describedby={descriptionId} aria-describedby={descriptionId}
{...restProps} {...restProps}
> >
<Switch.Thumb> <Switch.Thumb>
{#snippet child()} {#snippet child()}
<div class={cleanClass(label && 'flex items-center justify-between gap-1')}> <div class={cleanClass(label && 'flex items-center justify-between gap-1')}>
{#if label} {#if label}
<div class="text-start"> <div class="text-start">
<Label id={labelId} for={inputId} {label} {...labelProps} /> <Label id={labelId} for={inputId} {label} {...labelProps} />
{#if description} {#if description}
<Text color="secondary" size="small" id={descriptionId}>{description}</Text> <Text color="secondary" size="small" id={descriptionId}>{description}</Text>
{/if} {/if}
</div> </div>
{/if} {/if}
<span class={wrapper({ disabled })}> <span class={wrapper({ disabled })}>
<span class={bar({ fillColor: enabled ? color : 'default' })}> </span> <span class={bar({ fillColor: enabled ? color : 'default' })}> </span>
<span class={dot({ checked: enabled, fillColor: enabled ? color : 'default' })}></span> <span class={dot({ checked: enabled, fillColor: enabled ? color : 'default' })}></span>
</span> </span>
</div> </div>
{/snippet} {/snippet}
</Switch.Thumb> </Switch.Thumb>
</Switch.Root> </Switch.Root>
+27 -34
View File
@@ -1,42 +1,35 @@
<script lang="ts"> <script lang="ts">
import Text from '$lib/internal/Text.svelte'; import Text from '$lib/internal/Text.svelte';
import type { FontWeight, Size, TextColor, TextVariant } from '$lib/types.js'; import type { FontWeight, Size, TextColor, TextVariant } from '$lib/types.js';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
import { tv } from 'tailwind-variants'; import { tv } from 'tailwind-variants';
type Props = { type Props = {
size?: Size; size?: Size;
color?: TextColor; color?: TextColor;
fontWeight?: FontWeight; fontWeight?: FontWeight;
variant?: TextVariant; variant?: TextVariant;
class?: string; class?: string;
children: Snippet; children: Snippet;
} & HTMLAttributes<HTMLElement>; } & HTMLAttributes<HTMLElement>;
const { const { color, size, fontWeight = 'normal', children, class: className, ...restProps }: Props = $props();
color,
size,
fontWeight = 'normal',
children,
class: className,
...restProps
}: Props = $props();
const styles = tv({ const styles = tv({
variants: { variants: {
size: { size: {
tiny: 'text-xs', tiny: 'text-xs',
small: 'text-sm', small: 'text-sm',
medium: 'text-base', medium: 'text-base',
large: 'text-lg', large: 'text-lg',
giant: 'text-xl', giant: 'text-xl',
}, },
}, },
}); });
</script> </script>
<Text tag="p" {color} {fontWeight} class={cleanClass(styles({ size }), className)} {...restProps}> <Text tag="p" {color} {fontWeight} class={cleanClass(styles({ size }), className)} {...restProps}>
{@render children()} {@render children()}
</Text> </Text>
+103 -104
View File
@@ -1,120 +1,119 @@
<script lang="ts"> <script lang="ts">
import { getFieldContext } from '$lib/common/context.svelte.js'; import { getFieldContext } from '$lib/common/context.svelte.js';
import Label from '$lib/components/Label/Label.svelte'; import Label from '$lib/components/Label/Label.svelte';
import Text from '$lib/components/Text/Text.svelte'; import Text from '$lib/components/Text/Text.svelte';
import type { TextareaProps } from '$lib/types.js'; import type { TextareaProps } from '$lib/types.js';
import { cleanClass, generateId } from '$lib/utils.js'; import { cleanClass, generateId } from '$lib/utils.js';
import type { FormEventHandler } from 'svelte/elements'; import type { FormEventHandler } from 'svelte/elements';
import { tv } from 'tailwind-variants'; import { tv } from 'tailwind-variants';
let { let {
ref = $bindable(null), ref = $bindable(null),
containerRef = $bindable(null), containerRef = $bindable(null),
shape = 'semi-round', shape = 'semi-round',
size = 'medium', size = 'medium',
class: className, class: className,
grow, grow,
value = $bindable<string>(), value = $bindable<string>(),
...restProps ...restProps
}: TextareaProps = $props(); }: TextareaProps = $props();
const { label, description, readOnly, required, invalid, disabled, ...labelProps } = const { label, description, readOnly, required, invalid, disabled, ...labelProps } = $derived(getFieldContext());
$derived(getFieldContext());
const styles = tv({ const styles = tv({
base: 'w-full bg-gray-200 outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-400 dark:bg-gray-600 dark:disabled:bg-gray-800 dark:disabled:text-gray-200', base: 'w-full bg-gray-200 outline-none disabled:cursor-not-allowed disabled:bg-gray-300 disabled:text-gray-400 dark:bg-gray-600 dark:disabled:bg-gray-800 dark:disabled:text-gray-200',
variants: { variants: {
shape: { shape: {
rectangle: 'rounded-none', rectangle: 'rounded-none',
'semi-round': '', 'semi-round': '',
round: 'rounded-full', round: 'rounded-full',
}, },
padding: { padding: {
base: 'px-4 py-3', base: 'px-4 py-3',
round: 'px-5 py-3', round: 'px-5 py-3',
}, },
grow: { grow: {
true: 'resize-none', true: 'resize-none',
false: '', false: '',
}, },
roundedSize: { roundedSize: {
tiny: 'rounded-xl', tiny: 'rounded-xl',
small: 'rounded-xl', small: 'rounded-xl',
medium: 'rounded-2xl', medium: 'rounded-2xl',
large: 'rounded-2xl', large: 'rounded-2xl',
giant: 'rounded-2xl', giant: 'rounded-2xl',
}, },
textSize: { textSize: {
tiny: 'text-xs', tiny: 'text-xs',
small: 'text-sm', small: 'text-sm',
medium: 'text-base', medium: 'text-base',
large: 'text-lg', large: 'text-lg',
giant: 'text-xl', giant: 'text-xl',
}, },
invalid: { invalid: {
true: 'border-danger/80 border', true: 'border-danger/80 border',
false: '', false: '',
}, },
}, },
}); });
const id = generateId(); const id = generateId();
const inputId = `input-${id}`; const inputId = `input-${id}`;
const labelId = `label-${id}`; const labelId = `label-${id}`;
const descriptionId = $derived(description ? `description-${id}` : undefined); const descriptionId = $derived(description ? `description-${id}` : undefined);
const onInput: FormEventHandler<HTMLTextAreaElement> = (event) => { const onInput: FormEventHandler<HTMLTextAreaElement> = (event) => {
const element = event.target as HTMLTextAreaElement; const element = event.target as HTMLTextAreaElement;
if (element && grow) { if (element && grow) {
element.style.height = 'auto'; element.style.height = 'auto';
element.style.height = `${element.scrollHeight}px`; element.style.height = `${element.scrollHeight}px`;
} }
restProps?.oninput?.(event); restProps?.oninput?.(event);
}; };
</script> </script>
<div class="flex w-full flex-col gap-1" bind:this={containerRef}> <div class="flex w-full flex-col gap-1" bind:this={containerRef}>
{#if label} {#if label}
<Label id={labelId} for={inputId} {label} {...labelProps} /> <Label id={labelId} for={inputId} {label} {...labelProps} />
{/if} {/if}
{#if description} {#if description}
<Text color="secondary" size="small" id={descriptionId}>{description}</Text> <Text color="secondary" size="small" id={descriptionId}>{description}</Text>
{/if} {/if}
<div class="relative w-full"> <div class="relative w-full">
<textarea <textarea
oninput={onInput} oninput={onInput}
id={inputId} id={inputId}
aria-labelledby={label && labelId} aria-labelledby={label && labelId}
{required} {required}
aria-required={required} aria-required={required}
{disabled} {disabled}
aria-disabled={disabled} aria-disabled={disabled}
aria-describedby={descriptionId} aria-describedby={descriptionId}
readonly={readOnly} readonly={readOnly}
aria-readonly={readOnly} aria-readonly={readOnly}
class={cleanClass( class={cleanClass(
styles({ styles({
shape, shape,
textSize: size, textSize: size,
padding: shape === 'round' ? 'round' : 'base', padding: shape === 'round' ? 'round' : 'base',
grow, grow,
roundedSize: shape === 'semi-round' ? size : undefined, roundedSize: shape === 'semi-round' ? size : undefined,
invalid, invalid,
}), }),
className, className,
)} )}
bind:this={ref} bind:this={ref}
bind:value bind:value
{...restProps} {...restProps}
></textarea> ></textarea>
</div> </div>
</div> </div>
<style> <style>
textarea::-ms-reveal { textarea::-ms-reveal {
display: none; display: none;
} }
</style> </style>
@@ -1,52 +1,39 @@
<script lang="ts"> <script lang="ts">
import IconButton from '$lib/components/IconButton/IconButton.svelte'; import IconButton from '$lib/components/IconButton/IconButton.svelte';
import { theme, toggleTheme } from '$lib/services/theme.svelte.js'; import { theme, toggleTheme } from '$lib/services/theme.svelte.js';
import { t } from '$lib/services/translation.svelte.js'; import { t } from '$lib/services/translation.svelte.js';
import { import { Theme, type Color, type Size, type TranslationProps, type Variants } from '$lib/types.js';
Theme, import { cleanClass } from '$lib/utils.js';
type Color, import { mdiWeatherNight, mdiWeatherSunny } from '@mdi/js';
type Size,
type TranslationProps,
type Variants,
} from '$lib/types.js';
import { cleanClass } from '$lib/utils.js';
import { mdiWeatherNight, mdiWeatherSunny } from '@mdi/js';
type Props = { type Props = {
size?: Size; size?: Size;
class?: string; class?: string;
color?: Color; color?: Color;
variant?: Variants; variant?: Variants;
translations?: TranslationProps<'dark_theme'>; translations?: TranslationProps<'dark_theme'>;
onChange?: (theme: Theme) => void; onChange?: (theme: Theme) => void;
}; };
const { const { color = 'primary', variant = 'ghost', size, class: className, translations, onChange }: Props = $props();
color = 'primary',
variant = 'ghost',
size,
class: className,
translations,
onChange,
}: Props = $props();
const handleToggleTheme = () => { const handleToggleTheme = () => {
toggleTheme(); toggleTheme();
onChange?.(theme.value); onChange?.(theme.value);
}; };
const themeIcon = $derived(theme.value === Theme.Light ? mdiWeatherSunny : mdiWeatherNight); const themeIcon = $derived(theme.value === Theme.Light ? mdiWeatherSunny : mdiWeatherNight);
</script> </script>
<IconButton <IconButton
shape="round" shape="round"
{color} {color}
{size} {size}
{variant} {variant}
icon={themeIcon} icon={themeIcon}
onclick={handleToggleTheme} onclick={handleToggleTheme}
class={cleanClass(className)} class={cleanClass(className)}
aria-label={t('dark_theme', translations)} aria-label={t('dark_theme', translations)}
role="switch" role="switch"
aria-checked={theme.value === Theme.Dark} aria-checked={theme.value === Theme.Dark}
/> />
+13 -13
View File
@@ -1,15 +1,15 @@
export enum ChildKey { export enum ChildKey {
Field = 'field', Field = 'field',
HelperText = 'helped-text', HelperText = 'helped-text',
AppShell = 'app-shell', AppShell = 'app-shell',
AppShellHeader = 'app-shell-header', AppShellHeader = 'app-shell-header',
AppShellSidebar = 'app-shell-sidebar', AppShellSidebar = 'app-shell-sidebar',
Card = 'card', Card = 'card',
CardHeader = 'card-header', CardHeader = 'card-header',
CardBody = 'card-body', CardBody = 'card-body',
CardFooter = 'card-footer', CardFooter = 'card-footer',
Modal = 'modal', Modal = 'modal',
ModalHeader = 'modal-header', ModalHeader = 'modal-header',
ModalBody = 'modal-body', ModalBody = 'modal-body',
ModalFooter = 'modal-footer', ModalFooter = 'modal-footer',
} }
+177 -177
View File
@@ -1,193 +1,193 @@
<script lang="ts"> <script lang="ts">
import Icon from '$lib/components/Icon/Icon.svelte'; import Icon from '$lib/components/Icon/Icon.svelte';
import LoadingSpinner from '$lib/components/LoadingSpinner/LoadingSpinner.svelte'; import LoadingSpinner from '$lib/components/LoadingSpinner/LoadingSpinner.svelte';
import type { ButtonProps, Size } from '$lib/types.js'; import type { ButtonProps, Size } from '$lib/types.js';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import { Button as ButtonPrimitive } from 'bits-ui'; import { Button as ButtonPrimitive } from 'bits-ui';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements'; import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
import { tv } from 'tailwind-variants'; import { tv } from 'tailwind-variants';
type InternalButtonProps = ButtonProps & { type InternalButtonProps = ButtonProps & {
/** when true, button width to height ratio is 1:1 */ /** when true, button width to height ratio is 1:1 */
icon?: boolean; icon?: boolean;
}; };
let { let {
ref = $bindable(null), ref = $bindable(null),
type = 'button', type = 'button',
href, href,
external, external,
variant = 'filled', variant = 'filled',
color = 'primary', color = 'primary',
shape = 'semi-round', shape = 'semi-round',
size = 'medium', size = 'medium',
loading = false, loading = false,
fullWidth = false, fullWidth = false,
leadingIcon, leadingIcon,
trailingIcon, trailingIcon,
icon = false, icon = false,
class: className = '', class: className = '',
children, children,
...restProps ...restProps
}: InternalButtonProps = $props(); }: InternalButtonProps = $props();
const disabled = $derived((restProps as HTMLButtonAttributes).disabled || loading); const disabled = $derived((restProps as HTMLButtonAttributes).disabled || loading);
const buttonVariants = tv({ const buttonVariants = tv({
base: 'ring-offset-background focus-visible:ring-ring flex items-center justify-center gap-1 rounded-md text-sm font-medium whitespace-nowrap transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none', base: 'ring-offset-background focus-visible:ring-ring flex items-center justify-center gap-1 rounded-md text-sm font-medium whitespace-nowrap transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
variants: { variants: {
disabled: { disabled: {
true: 'disabled:pointer-events-none disabled:opacity-50 aria-disabled:opacity-50', true: 'disabled:pointer-events-none disabled:opacity-50 aria-disabled:opacity-50',
false: 'cursor-pointer', false: 'cursor-pointer',
}, },
shape: { shape: {
rectangle: 'rounded-none', rectangle: 'rounded-none',
'semi-round': 'rounded-xl', 'semi-round': 'rounded-xl',
round: 'rounded-full', round: 'rounded-full',
}, },
fullWidth: { fullWidth: {
true: 'w-full', true: 'w-full',
}, },
textPadding: { textPadding: {
tiny: 'px-3 py-1', tiny: 'px-3 py-1',
small: 'px-4 py-2', small: 'px-4 py-2',
medium: 'px-5 py-2', medium: 'px-5 py-2',
large: 'px-8 py-2.5', large: 'px-8 py-2.5',
giant: 'px-10 py-3', giant: 'px-10 py-3',
}, },
textSize: { textSize: {
tiny: 'text-xs', tiny: 'text-xs',
small: 'text-sm', small: 'text-sm',
medium: 'text-base', medium: 'text-base',
large: 'text-lg', large: 'text-lg',
giant: 'text-xl', giant: 'text-xl',
}, },
iconSize: { iconSize: {
tiny: 'h-6 w-6', tiny: 'h-6 w-6',
small: 'h-8 w-8', small: 'h-8 w-8',
medium: 'h-10 w-10', medium: 'h-10 w-10',
large: 'h-12 w-12', large: 'h-12 w-12',
giant: 'h-14 w-14', giant: 'h-14 w-14',
}, },
roundedSize: { roundedSize: {
tiny: 'rounded-lg', tiny: 'rounded-lg',
small: 'rounded-lg', small: 'rounded-lg',
medium: 'rounded-xl', medium: 'rounded-xl',
large: 'rounded-xl', large: 'rounded-xl',
giant: 'rounded-2xl', giant: 'rounded-2xl',
}, },
filledColor: { filledColor: {
primary: 'bg-primary text-light hover:bg-primary/80', primary: 'bg-primary text-light hover:bg-primary/80',
secondary: 'bg-dark text-light hover:bg-dark/80', secondary: 'bg-dark text-light hover:bg-dark/80',
success: 'bg-success text-light hover:bg-success/80', success: 'bg-success text-light hover:bg-success/80',
danger: 'bg-danger text-light hover:bg-danger/80', danger: 'bg-danger text-light hover:bg-danger/80',
warning: 'bg-warning text-light hover:bg-warning/80', warning: 'bg-warning text-light hover:bg-warning/80',
info: 'bg-info text-light hover:bg-info/80', info: 'bg-info text-light hover:bg-info/80',
}, },
outlineColor: { outlineColor: {
primary: 'border-primary bg-primary/10 text-primary hover:bg-primary/20 border', primary: 'border-primary bg-primary/10 text-primary hover:bg-primary/20 border',
secondary: 'border-dark bg-dark/10 text-dark hover:bg-dark/20 border', secondary: 'border-dark bg-dark/10 text-dark hover:bg-dark/20 border',
success: 'border-success bg-success/10 text-success hover:bg-success/20 border', success: 'border-success bg-success/10 text-success hover:bg-success/20 border',
danger: 'border-danger bg-danger/10 text-danger hover:bg-danger/20 border', danger: 'border-danger bg-danger/10 text-danger hover:bg-danger/20 border',
warning: 'border-warning bg-warning/10 text-warning hover:bg-warning/20 border', warning: 'border-warning bg-warning/10 text-warning hover:bg-warning/20 border',
info: 'border-info bg-info/10 text-info hover:bg-info/20 border', info: 'border-info bg-info/10 text-info hover:bg-info/20 border',
}, },
ghostColor: { ghostColor: {
primary: 'text-primary hover:bg-primary/10', primary: 'text-primary hover:bg-primary/10',
secondary: 'text-dark hover:bg-dark/10', secondary: 'text-dark hover:bg-dark/10',
success: 'text-success hover:bg-success/10', success: 'text-success hover:bg-success/10',
danger: 'text-danger hover:bg-danger/10', danger: 'text-danger hover:bg-danger/10',
warning: 'text-warning hover:bg-warning/10', warning: 'text-warning hover:bg-warning/10',
info: 'text-info hover:bg-info/10', info: 'text-info hover:bg-info/10',
}, },
}, },
}); });
const spinnerSizes: Record<Size, Size> = { const spinnerSizes: Record<Size, Size> = {
tiny: 'tiny', tiny: 'tiny',
small: 'tiny', small: 'tiny',
medium: 'small', medium: 'small',
large: 'medium', large: 'medium',
giant: 'large', giant: 'large',
}; };
const classList = $derived( const classList = $derived(
cleanClass( cleanClass(
twMerge( twMerge(
buttonVariants({ buttonVariants({
shape, shape,
fullWidth, fullWidth,
textPadding: icon ? undefined : size, textPadding: icon ? undefined : size,
textSize: size, textSize: size,
iconSize: icon ? size : undefined, iconSize: icon ? size : undefined,
disabled, disabled,
roundedSize: shape === 'semi-round' ? size : undefined, roundedSize: shape === 'semi-round' ? size : undefined,
filledColor: variant === 'filled' ? color : undefined, filledColor: variant === 'filled' ? color : undefined,
outlineColor: variant === 'outline' ? color : undefined, outlineColor: variant === 'outline' ? color : undefined,
ghostColor: variant === 'ghost' ? color : undefined, ghostColor: variant === 'ghost' ? color : undefined,
}), }),
className, className,
), ),
), ),
); );
const iconSizes = { const iconSizes = {
tiny: 'h-4 w-4', tiny: 'h-4 w-4',
small: 'h-4 w-4', small: 'h-4 w-4',
medium: 'h-4 w-4', medium: 'h-4 w-4',
large: 'h-6 w-6', large: 'h-6 w-6',
giant: 'h-8 w-8', giant: 'h-8 w-8',
}; };
</script> </script>
{#snippet content()} {#snippet content()}
{#if leadingIcon && !loading} {#if leadingIcon && !loading}
<div> <div>
<Icon size="100%" class={iconSizes[size]} icon={leadingIcon} aria-hidden /> <Icon size="100%" class={iconSizes[size]} icon={leadingIcon} aria-hidden />
</div> </div>
{/if} {/if}
{@render children?.()} {@render children?.()}
{#if trailingIcon} {#if trailingIcon}
<Icon size="100%" class={iconSizes[size]} icon={trailingIcon} aria-hidden /> <Icon size="100%" class={iconSizes[size]} icon={trailingIcon} aria-hidden />
{/if} {/if}
{/snippet} {/snippet}
{#if href} {#if href}
<a <a
bind:this={ref} bind:this={ref}
{href} {href}
class={classList} class={classList}
aria-disabled={disabled} aria-disabled={disabled}
target={external ? '_blank' : undefined} target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined} rel={external ? 'noopener noreferrer' : undefined}
{...restProps as HTMLAnchorAttributes} {...restProps as HTMLAnchorAttributes}
> >
{#if loading} {#if loading}
<div class="flex items-center justify-center gap-2"> <div class="flex items-center justify-center gap-2">
<LoadingSpinner {color} size={spinnerSizes[size]} /> <LoadingSpinner {color} size={spinnerSizes[size]} />
{@render content()} {@render content()}
</div> </div>
{:else} {:else}
{@render content()} {@render content()}
{/if} {/if}
</a> </a>
{:else} {:else}
<ButtonPrimitive.Root <ButtonPrimitive.Root
bind:ref bind:ref
class={classList} class={classList}
type={type as HTMLButtonAttributes['type']} type={type as HTMLButtonAttributes['type']}
{...restProps as HTMLButtonAttributes} {...restProps as HTMLButtonAttributes}
{disabled} {disabled}
aria-disabled={disabled} aria-disabled={disabled}
> >
{#if loading} {#if loading}
<div class="flex items-center justify-center gap-2"> <div class="flex items-center justify-center gap-2">
<LoadingSpinner {color} size={spinnerSizes[size]} /> <LoadingSpinner {color} size={spinnerSizes[size]} />
{@render content()} {@render content()}
</div> </div>
{:else} {:else}
{@render content()} {@render content()}
{/if} {/if}
</ButtonPrimitive.Root> </ButtonPrimitive.Root>
{/if} {/if}
+21 -21
View File
@@ -1,28 +1,28 @@
<script lang="ts"> <script lang="ts">
import { ChildKey } from '$lib/constants.js'; import { ChildKey } from '$lib/constants.js';
import type { ChildData } from '$lib/types.js'; import type { ChildData } from '$lib/types.js';
import { withPrefix } from '$lib/utils.js'; import { withPrefix } from '$lib/utils.js';
import { getContext, type Snippet } from 'svelte'; import { getContext, type Snippet } from 'svelte';
type ContextType = { type ContextType = {
register: (key: ChildKey, data: () => ChildData) => void; register: (key: ChildKey, data: () => ChildData) => void;
}; };
type Props = { type Props = {
for: ChildKey; for: ChildKey;
as: ChildKey; as: ChildKey;
class?: string; class?: string;
children: Snippet; children: Snippet;
}; };
const { for: key, as, children, class: className }: Props = $props(); const { for: key, as, children, class: className }: Props = $props();
const context = getContext<ContextType>(withPrefix(key)); const context = getContext<ContextType>(withPrefix(key));
const data = $derived({ snippet: children, class: className }); const data = $derived({ snippet: children, class: className });
if (context) { if (context) {
context.register(as, () => data); context.register(as, () => data);
} else { } else {
console.log('Unable to find context for key:', key); console.log('Unable to find context for key:', key);
} }
</script> </script>
+135 -142
View File
@@ -1,163 +1,156 @@
<script lang="ts"> <script lang="ts">
import { getFieldContext } from '$lib/common/context.svelte.js'; import { getFieldContext } from '$lib/common/context.svelte.js';
import Field from '$lib/components/Field/Field.svelte'; import Field from '$lib/components/Field/Field.svelte';
import Icon from '$lib/components/Icon/Icon.svelte'; import Icon from '$lib/components/Icon/Icon.svelte';
import IconButton from '$lib/components/IconButton/IconButton.svelte'; import IconButton from '$lib/components/IconButton/IconButton.svelte';
import Input from '$lib/components/Input/Input.svelte'; import Input from '$lib/components/Input/Input.svelte';
import Label from '$lib/components/Label/Label.svelte'; import Label from '$lib/components/Label/Label.svelte';
import type { SelectCommonProps, SelectItem } from '$lib/types.js'; import type { SelectCommonProps, SelectItem } from '$lib/types.js';
import { cleanClass, generateId } from '$lib/utils.js'; import { cleanClass, generateId } from '$lib/utils.js';
import { mdiArrowDown, mdiArrowUp, mdiCheck, mdiUnfoldMoreHorizontal } from '@mdi/js'; import { mdiArrowDown, mdiArrowUp, mdiCheck, mdiUnfoldMoreHorizontal } from '@mdi/js';
import { Select } from 'bits-ui'; import { Select } from 'bits-ui';
type T = SelectItem; type T = SelectItem;
type Props = { type Props = {
multiple?: boolean; multiple?: boolean;
values: T[]; values: T[];
asLabel?: (items: T[]) => string; asLabel?: (items: T[]) => string;
onChange?: (values: T[]) => void; onChange?: (values: T[]) => void;
} & SelectCommonProps<T>; } & SelectCommonProps<T>;
let { let {
data, data,
shape, shape,
size = 'medium', size = 'medium',
multiple = false, multiple = false,
values = $bindable([]), values = $bindable([]),
onChange, onChange,
asLabel = (options: T[]) => options.map(({ label }) => label).join(', '), asLabel = (options: T[]) => options.map(({ label }) => label).join(', '),
placeholder, placeholder,
class: className, class: className,
}: Props = $props(); }: Props = $props();
const asOptions = (items: string[] | T[]) => { const asOptions = (items: string[] | T[]) => {
return items.map((item) => { return items.map((item) => {
if (typeof item === 'string') { if (typeof item === 'string') {
return { value: item, label: item } as T; return { value: item, label: item } as T;
} }
const label = item.label ?? item.value; const label = item.label ?? item.value;
return { ...item, label }; return { ...item, label };
}); });
}; };
const options = $derived(asOptions(data)); const options = $derived(asOptions(data));
const { readOnly, required, invalid, disabled, label, ...labelProps } = const { readOnly, required, invalid, disabled, label, ...labelProps } = $derived(getFieldContext());
$derived(getFieldContext());
const id = generateId(); const id = generateId();
const inputId = `input-${id}`; const inputId = `input-${id}`;
const labelId = `label-${id}`; const labelId = `label-${id}`;
const selectedLabel = $derived(asLabel(values)); const selectedLabel = $derived(asLabel(values));
let inputRef = $state<HTMLElement | null>(null); let inputRef = $state<HTMLElement | null>(null);
let contentRef = $state<HTMLElement | null>(null); let contentRef = $state<HTMLElement | null>(null);
let ref = $state<HTMLElement | null>(null); let ref = $state<HTMLElement | null>(null);
$effect(() => { $effect(() => {
if (ref && contentRef) { if (ref && contentRef) {
contentRef.style.width = `${ref.clientWidth}px`; contentRef.style.width = `${ref.clientWidth}px`;
} }
}); });
const findOption = (value: string) => options.find((option) => option.value === value); const findOption = (value: string) => options.find((option) => option.value === value);
const onValueChange = (items: string[] | string) => { const onValueChange = (items: string[] | string) => {
values = multiple values = multiple
? ((items as string[]).map(findOption) as T[]) ? ((items as string[]).map(findOption) as T[])
: [findOption(items as string) as T].filter(Boolean); : [findOption(items as string) as T].filter(Boolean);
onChange?.(values); onChange?.(values);
}; };
let internalValue = $derived( let internalValue = $derived(multiple ? values.map(({ value }) => value) : (values[0]?.value ?? ''));
multiple ? values.map(({ value }) => value) : (values[0]?.value ?? ''),
);
</script> </script>
<div class={cleanClass('flex flex-col gap-1', className)} bind:this={ref}> <div class={cleanClass('flex flex-col gap-1', className)} bind:this={ref}>
{#if label} {#if label}
<Label id={labelId} for={inputId} {label} {...labelProps} /> <Label id={labelId} for={inputId} {label} {...labelProps} />
{/if} {/if}
<Select.Root <Select.Root type={multiple ? 'multiple' : 'single'} bind:value={internalValue as never} {onValueChange}>
type={multiple ? 'multiple' : 'single'} <Select.Trigger
bind:value={internalValue as never} {disabled}
{onValueChange} class="w-full items-center gap-1 rounded-lg focus-visible:outline-none"
> aria-label={placeholder}
<Select.Trigger >
{disabled} <Field {readOnly} {required} {disabled} {invalid}>
class="w-full items-center gap-1 rounded-lg focus-visible:outline-none" <Input
aria-label={placeholder} bind:containerRef={inputRef}
> id={inputId}
<Field {readOnly} {required} {disabled} {invalid}> {size}
<Input {shape}
bind:containerRef={inputRef} {placeholder}
id={inputId} value={selectedLabel}
{size} readonly
{shape} aria-labelledby={labelId}
{placeholder} aria-readonly
value={selectedLabel} >
readonly {#snippet trailingIcon()}
aria-labelledby={labelId} <IconButton
aria-readonly variant="ghost"
> shape="round"
{#snippet trailingIcon()} color="secondary"
<IconButton class="m-1"
variant="ghost" icon={mdiUnfoldMoreHorizontal}
shape="round" {disabled}
color="secondary" aria-label="Expand"
class="m-1" />
icon={mdiUnfoldMoreHorizontal} {/snippet}
{disabled} </Input>
aria-label="Expand" </Field>
/> </Select.Trigger>
{/snippet} <Select.Portal>
</Input> <Select.Content
</Field> bind:ref={contentRef}
</Select.Trigger> class="bg-light text-dark max-h-96 rounded-xl border py-3 outline-none select-none"
<Select.Portal> sideOffset={10}
<Select.Content >
bind:ref={contentRef} <Select.ScrollUpButton class="flex w-full items-center justify-center">
class="bg-light text-dark max-h-96 rounded-xl border py-3 outline-none select-none" <Icon icon={mdiArrowUp} />
sideOffset={10} </Select.ScrollUpButton>
> <Select.Viewport>
<Select.ScrollUpButton class="flex w-full items-center justify-center"> {#each options as { value, label, disabled }, i (i + value)}
<Icon icon={mdiArrowUp} /> <Select.Item
</Select.ScrollUpButton> class={cleanClass(
<Select.Viewport> 'hover:bg-subtle data-[selected]:bg-primary/10 flex h-10 w-full items-center px-5 py-3 text-sm duration-75 outline-none select-none data-disabled:opacity-50',
{#each options as { value, label, disabled }, i (i + value)} disabled ? 'cursor-not-allowed' : 'cursor-pointer',
<Select.Item )}
class={cleanClass( {value}
'hover:bg-subtle data-[selected]:bg-primary/10 flex h-10 w-full items-center px-5 py-3 text-sm duration-75 outline-none select-none data-disabled:opacity-50', {label}
disabled ? 'cursor-not-allowed' : 'cursor-pointer', {disabled}
)} >
{value} {#snippet children({ selected })}
{label} <div
{disabled} class="flex cursor-pointer items-center justify-center gap-2 text-sm font-medium whitespace-nowrap transition-colors"
> >
{#snippet children({ selected })} <span>{label}</span>
<div </div>
class="flex cursor-pointer items-center justify-center gap-2 text-sm font-medium whitespace-nowrap transition-colors" {#if selected}
> <div class="ms-auto">
<span>{label}</span> <Icon icon={mdiCheck} />
</div> </div>
{#if selected} {/if}
<div class="ms-auto"> {/snippet}
<Icon icon={mdiCheck} /> </Select.Item>
</div> {/each}
{/if} </Select.Viewport>
{/snippet} <Select.ScrollDownButton class="flex w-full items-center justify-center">
</Select.Item> <Icon icon={mdiArrowDown} />
{/each} </Select.ScrollDownButton>
</Select.Viewport> </Select.Content>
<Select.ScrollDownButton class="flex w-full items-center justify-center"> </Select.Portal>
<Icon icon={mdiArrowDown} /> </Select.Root>
</Select.ScrollDownButton>
</Select.Content>
</Select.Portal>
</Select.Root>
</div> </div>
+42 -50
View File
@@ -1,60 +1,52 @@
<script lang="ts"> <script lang="ts">
import type { FontWeight, HeadingColor, HeadingTag, TextVariant } from '$lib/types.js'; import type { FontWeight, HeadingColor, HeadingTag, TextVariant } from '$lib/types.js';
import { cleanClass } from '$lib/utils.js'; import { cleanClass } from '$lib/utils.js';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
import { tv } from 'tailwind-variants'; import { tv } from 'tailwind-variants';
type Props = { type Props = {
/** /**
* The HTML element type. * The HTML element type.
*/ */
tag?: HeadingTag; tag?: HeadingTag;
fontWeight?: FontWeight; fontWeight?: FontWeight;
variant?: TextVariant; variant?: TextVariant;
color?: HeadingColor; color?: HeadingColor;
class?: string; class?: string;
children: Snippet; children: Snippet;
} & HTMLAttributes<HTMLElement>; } & HTMLAttributes<HTMLElement>;
const { const { tag = 'p', color, fontWeight, variant, class: className, children, ...restProps }: Props = $props();
tag = 'p',
color,
fontWeight,
variant,
class: className,
children,
...restProps
}: Props = $props();
const styles = tv({ const styles = tv({
variants: { variants: {
color: { color: {
muted: 'text-gray-600 dark:text-gray-400', muted: 'text-gray-600 dark:text-gray-400',
primary: 'text-primary', primary: 'text-primary',
secondary: 'text-dark', secondary: 'text-dark',
success: 'text-success', success: 'text-success',
danger: 'text-danger', danger: 'text-danger',
warning: 'text-warning', warning: 'text-warning',
info: 'text-info', info: 'text-info',
}, },
fontWeight: { fontWeight: {
light: 'font-light', light: 'font-light',
normal: 'font-normal', normal: 'font-normal',
'semi-bold': 'font-semibold', 'semi-bold': 'font-semibold',
bold: 'font-bold', bold: 'font-bold',
'extra-bold': 'font-extrabold', 'extra-bold': 'font-extrabold',
}, },
variant: { variant: {
italic: 'italic', italic: 'italic',
}, },
}, },
}); });
const classList = $derived(cleanClass(styles({ color, fontWeight, variant }), className)); const classList = $derived(cleanClass(styles({ color, fontWeight, variant }), className));
</script> </script>
<svelte:element this={tag} class={classList} {...restProps}> <svelte:element this={tag} class={classList} {...restProps}>
{@render children()} {@render children()}
</svelte:element> </svelte:element>
@@ -1,124 +1,122 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
export type CommandItem = { export type CommandItem = {
icon: string; icon: string;
iconClass: string; iconClass: string;
type: string; type: string;
title: string; title: string;
description?: string; description?: string;
text: string; text: string;
} & ({ href: string; external?: boolean } | { action: () => void }); } & ({ href: string; external?: boolean } | { action: () => void });
export const asText = (...items: unknown[]) => { export const asText = (...items: unknown[]) => {
return items return items
.filter((item) => item !== undefined && item !== null) .filter((item) => item !== undefined && item !== null)
.map((items) => String(items)) .map((items) => String(items))
.join('|') .join('|')
.toLowerCase(); .toLowerCase();
}; };
const isEqual = (a: CommandItem, b: CommandItem) => { const isEqual = (a: CommandItem, b: CommandItem) => {
return a.title === b.title && a.type === b.type; return a.title === b.title && a.type === b.type;
}; };
const isMatch = (item: CommandItem, query: string): boolean => { const isMatch = (item: CommandItem, query: string): boolean => {
if (!query) { if (!query) {
return true; return true;
} }
return item.text.includes(query); return item.text.includes(query);
}; };
class CommandPaletteManager { class CommandPaletteManager {
isEnabled = $state(false); isEnabled = $state(false);
isOpen = $state(false); isOpen = $state(false);
query = $state(''); query = $state('');
selectedIndex = $state(0); selectedIndex = $state(0);
private normalizedQuery = $derived(this.query.toLowerCase()); private normalizedQuery = $derived(this.query.toLowerCase());
items: CommandItem[] = []; items: CommandItem[] = [];
filteredItems = $derived( filteredItems = $derived(this.items.filter((item) => isMatch(item, this.normalizedQuery)).slice(0, 100));
this.items.filter((item) => isMatch(item, this.normalizedQuery)).slice(0, 100), recentItems = $state<CommandItem[]>([]);
); results = $derived(this.query ? this.filteredItems : this.recentItems);
recentItems = $state<CommandItem[]>([]);
results = $derived(this.query ? this.filteredItems : this.recentItems);
enable() { enable() {
this.isEnabled = true; this.isEnabled = true;
} }
async open() { async open() {
if (!this.isEnabled || this.isOpen) { if (!this.isEnabled || this.isOpen) {
return; return;
} }
this.selectedIndex = 0; this.selectedIndex = 0;
this.isOpen = true; this.isOpen = true;
} }
close() { close() {
if (!this.isEnabled || !this.isOpen) { if (!this.isEnabled || !this.isOpen) {
return; return;
} }
this.query = ''; this.query = '';
this.isOpen = false; this.isOpen = false;
} }
async select(selectedIndex?: number) { async select(selectedIndex?: number) {
const selected = this.results[selectedIndex ?? this.selectedIndex]; const selected = this.results[selectedIndex ?? this.selectedIndex];
if (!selected) { if (!selected) {
return; return;
} }
// no duplicates // no duplicates
this.recentItems = this.recentItems.filter((item) => !isEqual(item, selected)); this.recentItems = this.recentItems.filter((item) => !isEqual(item, selected));
this.recentItems.unshift(selected); this.recentItems.unshift(selected);
this.recentItems = this.recentItems.slice(0, 5); this.recentItems = this.recentItems.slice(0, 5);
if ('href' in selected) { if ('href' in selected) {
if (selected.href.startsWith('http') || selected.external) { if (selected.href.startsWith('http') || selected.external) {
window.open(selected.href, '_blank'); window.open(selected.href, '_blank');
} else { } else {
// eslint-disable-next-line svelte/no-navigation-without-resolve // eslint-disable-next-line svelte/no-navigation-without-resolve
await goto(selected.href); await goto(selected.href);
} }
} else { } else {
await selected.action(); await selected.action();
} }
this.close(); this.close();
} }
remove(index: number) { remove(index: number) {
this.recentItems.splice(index, 1); this.recentItems.splice(index, 1);
} }
up() { up() {
this.selectedIndex = (this.selectedIndex - 1 + this.results.length) % this.results.length; this.selectedIndex = (this.selectedIndex - 1 + this.results.length) % this.results.length;
} }
down() { down() {
this.selectedIndex = (this.selectedIndex + 1) % this.results.length; this.selectedIndex = (this.selectedIndex + 1) % this.results.length;
} }
reset() { reset() {
this.items = []; this.items = [];
this.isOpen = false; this.isOpen = false;
this.query = ''; this.query = '';
} }
addCommands(itemOrItems: CommandItem | CommandItem[]) { addCommands(itemOrItems: CommandItem | CommandItem[]) {
const items = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems]; const items = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems];
this.items.push(...items); this.items.push(...items);
} }
removeCommands(itemOrItems: CommandItem | CommandItem[]) { removeCommands(itemOrItems: CommandItem | CommandItem[]) {
const items = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems]; const items = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems];
for (const remoteItem of items) { for (const remoteItem of items) {
this.items = this.items.filter((item) => !isEqual(item, remoteItem)); this.items = this.items.filter((item) => !isEqual(item, remoteItem));
} }
} }
} }
export const commandPaletteManager = new CommandPaletteManager(); export const commandPaletteManager = new CommandPaletteManager();
+36 -40
View File
@@ -2,57 +2,53 @@ import { mount, unmount, type Component, type ComponentProps } from 'svelte';
import ConfirmModal from '$lib/components/ConfirmModal/ConfirmModal.svelte'; import ConfirmModal from '$lib/components/ConfirmModal/ConfirmModal.svelte';
type OnCloseData<T> = T extends { onClose: (data?: infer R) => void } type OnCloseData<T> = T extends { onClose: (data?: infer R) => void }
? R | undefined ? R | undefined
: T extends { onClose: (data: infer R) => void } : T extends { onClose: (data: infer R) => void }
? R ? R
: never; : never;
type ExtendsEmptyObject<T> = keyof T extends never ? never : T; type ExtendsEmptyObject<T> = keyof T extends never ? never : T;
type StripValueIfOptional<T> = T extends undefined ? undefined : T; type StripValueIfOptional<T> = T extends undefined ? undefined : T;
// if the modal does not expect any props, makes the props param optional but also allows passing `{}` and `undefined` // if the modal does not expect any props, makes the props param optional but also allows passing `{}` and `undefined`
type OptionalParamIfEmpty<T> = type OptionalParamIfEmpty<T> = ExtendsEmptyObject<T> extends never ? [] | [Record<string, never> | undefined] : [T];
ExtendsEmptyObject<T> extends never ? [] | [Record<string, never> | undefined] : [T];
class ModalManager { class ModalManager {
show<T extends object>( show<T extends object>(Component: Component<T>, ...props: OptionalParamIfEmpty<Omit<T, 'onClose'>>) {
Component: Component<T>, return this.open(Component, ...props).onClose;
...props: OptionalParamIfEmpty<Omit<T, 'onClose'>> }
) {
return this.open(Component, ...props).onClose;
}
open<T extends object, K = OnCloseData<T>>( open<T extends object, K = OnCloseData<T>>(
Component: Component<T>, Component: Component<T>,
...props: OptionalParamIfEmpty<Omit<T, 'onClose'>> ...props: OptionalParamIfEmpty<Omit<T, 'onClose'>>
) { ) {
let modal: object = {}; let modal: object = {};
let onClose: (...args: [StripValueIfOptional<K>]) => Promise<void>; let onClose: (...args: [StripValueIfOptional<K>]) => Promise<void>;
const deferred = new Promise<StripValueIfOptional<K>>((resolve) => { const deferred = new Promise<StripValueIfOptional<K>>((resolve) => {
onClose = async (...args: [StripValueIfOptional<K>]) => { onClose = async (...args: [StripValueIfOptional<K>]) => {
await unmount(modal); await unmount(modal);
// make sure bits-ui clean up finishes before resolving // make sure bits-ui clean up finishes before resolving
setTimeout(() => resolve(args?.[0]), 10); setTimeout(() => resolve(args?.[0]), 10);
}; };
modal = mount(Component, { modal = mount(Component, {
target: document.body, target: document.body,
props: { props: {
...((props?.[0] ?? {}) as T), ...((props?.[0] ?? {}) as T),
onClose, onClose,
}, },
}); });
}); });
return { return {
onClose: deferred, onClose: deferred,
close: (...args: [StripValueIfOptional<K>]) => onClose(args[0]), close: (...args: [StripValueIfOptional<K>]) => onClose(args[0]),
}; };
} }
showDialog(options: Omit<ComponentProps<typeof ConfirmModal>, 'onClose'>) { showDialog(options: Omit<ComponentProps<typeof ConfirmModal>, 'onClose'>) {
return this.show(ConfirmModal, options); return this.show(ConfirmModal, options);
} }
} }
export const modalManager = new ModalManager(); export const modalManager = new ModalManager();
+31 -31
View File
@@ -3,49 +3,49 @@ import { browser } from '$app/environment';
const asKey = (key: string) => `immich-ui-${key}`; const asKey = (key: string) => `immich-ui-${key}`;
const readPreference = <T>(key: string, defaults: T) => { const readPreference = <T>(key: string, defaults: T) => {
if (!browser || !window?.localStorage) { if (!browser || !window?.localStorage) {
throw new Error('Local storage is not available'); throw new Error('Local storage is not available');
} }
const text = window.localStorage.getItem(asKey(key)); const text = window.localStorage.getItem(asKey(key));
const stored = text ? (JSON.parse(text) as T) : {}; const stored = text ? (JSON.parse(text) as T) : {};
return { ...defaults, ...stored }; return { ...defaults, ...stored };
}; };
const writePreference = <T>(key: string, value: T) => { const writePreference = <T>(key: string, value: T) => {
if (!browser || !window.localStorage) { if (!browser || !window.localStorage) {
throw new Error('Local storage is not available'); throw new Error('Local storage is not available');
} }
const text = JSON.stringify(value); const text = JSON.stringify(value);
window.localStorage.setItem(asKey(key), text); window.localStorage.setItem(asKey(key), text);
}; };
type PreferenceOptions<T> = { type PreferenceOptions<T> = {
key: string; key: string;
defaults: T; defaults: T;
onReadError?: (error: unknown) => void; onReadError?: (error: unknown) => void;
onWriteError?: (error: unknown) => void; onWriteError?: (error: unknown) => void;
}; };
export const preference = <T>(options: PreferenceOptions<T>) => { export const preference = <T>(options: PreferenceOptions<T>) => {
const { key, defaults, onReadError, onWriteError } = options; const { key, defaults, onReadError, onWriteError } = options;
let initialValue = defaults; let initialValue = defaults;
try { try {
initialValue = readPreference<T>(key, defaults); initialValue = readPreference<T>(key, defaults);
} catch (error) { } catch (error) {
onReadError?.(error); onReadError?.(error);
} }
const state = $state<T>(initialValue); const state = $state<T>(initialValue);
const sync = () => { const sync = () => {
try { try {
writePreference(key, state); writePreference(key, state);
} catch (error) { } catch (error) {
onWriteError?.(error); onWriteError?.(error);
} }
}; };
return { state, sync }; return { state, sync };
}; };
+53 -54
View File
@@ -3,93 +3,92 @@ import { preference } from '$lib/services/preference.svelte.js';
import { Theme } from '$lib/types.js'; import { Theme } from '$lib/types.js';
export type ThemeOptions = { export type ThemeOptions = {
lightClass?: string; lightClass?: string;
darkClass?: string; darkClass?: string;
selector?: string; selector?: string;
}; };
const defaultOptions: ThemeOptions = { const defaultOptions: ThemeOptions = {
darkClass: 'dark', darkClass: 'dark',
selector: 'body', selector: 'body',
}; };
let options: ThemeOptions = defaultOptions; let options: ThemeOptions = defaultOptions;
export const setThemeOptions = (newOptions: ThemeOptions) => export const setThemeOptions = (newOptions: ThemeOptions) => (options = { ...defaultOptions, ...newOptions });
(options = { ...defaultOptions, ...newOptions });
type ThemePreference = { value: Theme }; type ThemePreference = { value: Theme };
const defaultTheme: ThemePreference = { value: Theme.Dark }; const defaultTheme: ThemePreference = { value: Theme.Dark };
const { state, sync: syncToLocalStorage } = preference<ThemePreference>({ const { state, sync: syncToLocalStorage } = preference<ThemePreference>({
key: 'theme', key: 'theme',
defaults: defaultTheme, defaults: defaultTheme,
onReadError: (error) => console.log(`Preference read error: ${error}`), onReadError: (error) => console.log(`Preference read error: ${error}`),
onWriteError: (error) => console.log(`Preference write error: ${error}`), onWriteError: (error) => console.log(`Preference write error: ${error}`),
}); });
export const theme = state; export const theme = state;
export const onThemeChange = () => { export const onThemeChange = () => {
syncToDom(); syncToDom();
syncToLocalStorage(); syncToLocalStorage();
}; };
const syncToDom = () => { const syncToDom = () => {
const { lightClass, darkClass, selector } = options; const { lightClass, darkClass, selector } = options;
if (!browser || !selector) { if (!browser || !selector) {
return; return;
} }
const element = document.querySelector(selector); const element = document.querySelector(selector);
if (!element) { if (!element) {
return; return;
} }
switch (theme.value) { switch (theme.value) {
case Theme.Dark: { case Theme.Dark: {
if (lightClass) { if (lightClass) {
element.classList.remove(lightClass); element.classList.remove(lightClass);
} }
if (darkClass) { if (darkClass) {
element.classList.add(darkClass); element.classList.add(darkClass);
} }
const darkReaderLock = document.createElement('meta'); const darkReaderLock = document.createElement('meta');
darkReaderLock.name = 'darkreader-lock'; darkReaderLock.name = 'darkreader-lock';
document.head.appendChild(darkReaderLock); document.head.appendChild(darkReaderLock);
break; break;
} }
case Theme.Light: { case Theme.Light: {
if (lightClass) { if (lightClass) {
element.classList.add(lightClass); element.classList.add(lightClass);
} }
if (darkClass) { if (darkClass) {
element.classList.remove(darkClass); element.classList.remove(darkClass);
} }
const darkReaderLock = document.querySelector('head > meta[name=darkreader-lock]'); const darkReaderLock = document.querySelector('head > meta[name=darkreader-lock]');
if (darkReaderLock) { if (darkReaderLock) {
document.head.removeChild(darkReaderLock); document.head.removeChild(darkReaderLock);
} }
break; break;
} }
} }
}; };
export const toggleTheme = () => { export const toggleTheme = () => {
theme.value = theme.value === Theme.Dark ? Theme.Light : Theme.Dark; theme.value = theme.value === Theme.Dark ? Theme.Light : Theme.Dark;
onThemeChange(); onThemeChange();
}; };
export const initializeTheme = (options?: ThemeOptions) => { export const initializeTheme = (options?: ThemeOptions) => {
if (options) { if (options) {
setThemeOptions(options); setThemeOptions(options);
} }
syncToDom(); syncToDom();
}; };
+19 -21
View File
@@ -1,37 +1,35 @@
import type { TranslationProps } from '$lib/types.js'; import type { TranslationProps } from '$lib/types.js';
const defaultTranslations = { const defaultTranslations = {
// common // common
cancel: 'Cancel', cancel: 'Cancel',
close: 'Close', close: 'Close',
confirm: 'Confirm', confirm: 'Confirm',
search_placeholder: 'Search...', search_placeholder: 'Search...',
search_no_results: 'No results', search_no_results: 'No results',
search_recently_used: 'Recently used', search_recently_used: 'Recently used',
// modal // modal
prompt_default: 'Are you sure you want to do this?', prompt_default: 'Are you sure you want to do this?',
// password input // password input
show_password: 'Show password', show_password: 'Show password',
hide_password: 'Hide password', hide_password: 'Hide password',
// theme switcher // theme switcher
dark_theme: 'Toggle dark theme', dark_theme: 'Toggle dark theme',
// command palette // command palette
command_palette_prompt_default: 'Quickly find pages, actions, or commands', command_palette_prompt_default: 'Quickly find pages, actions, or commands',
}; };
export type Translations = typeof defaultTranslations; export type Translations = typeof defaultTranslations;
let translations = $state<Translations>(defaultTranslations); let translations = $state<Translations>(defaultTranslations);
export const translate = <T extends keyof Translations>( export const translate = <T extends keyof Translations>(key: T, overrides?: TranslationProps<T>): string =>
key: T, overrides?.[key] ?? translations[key];
overrides?: TranslationProps<T>,
): string => overrides?.[key] ?? translations[key];
export const t = translate; export const t = translate;
export const setTranslations = (newTranslations: Partial<Translations>) => { export const setTranslations = (newTranslations: Partial<Translations>) => {
translations = { ...defaultTranslations, ...newTranslations }; translations = { ...defaultTranslations, ...newTranslations };
}; };
+59 -75
View File
@@ -1,84 +1,68 @@
<script lang="ts"> <script lang="ts">
import Heading from '$lib/components/Heading/Heading.svelte'; import Heading from '$lib/components/Heading/Heading.svelte';
import Stack from '$lib/components/Stack/Stack.svelte'; import Stack from '$lib/components/Stack/Stack.svelte';
import VStack from '$lib/components/Stack/VStack.svelte'; import VStack from '$lib/components/Stack/VStack.svelte';
import Text from '$lib/components/Text/Text.svelte'; import Text from '$lib/components/Text/Text.svelte';
import ThemeSwitcher from '$lib/components/ThemeSwitcher/ThemeSwitcher.svelte'; import ThemeSwitcher from '$lib/components/ThemeSwitcher/ThemeSwitcher.svelte';
import { Constants } from '$lib/site/constants.js'; import { Constants } from '$lib/site/constants.js';
import SiteFooterLink from '$lib/site/SiteFooterLink.svelte'; import SiteFooterLink from '$lib/site/SiteFooterLink.svelte';
import { import {
mdiBookshelf, mdiBookshelf,
mdiChartGantt, mdiChartGantt,
mdiCubeOutline, mdiCubeOutline,
mdiHomeSearchOutline, mdiHomeSearchOutline,
mdiKey, mdiKey,
mdiOfficeBuildingOutline, mdiOfficeBuildingOutline,
mdiScriptTextOutline, mdiScriptTextOutline,
mdiServerOutline, mdiServerOutline,
mdiShoppingOutline, mdiShoppingOutline,
} from '@mdi/js'; } from '@mdi/js';
import { siAndroid, siApple, siDiscord, siGithub, siReddit } from 'simple-icons'; import { siAndroid, siApple, siDiscord, siGithub, siReddit } from 'simple-icons';
</script> </script>
<div class="bg-dark/10 mt-16 rounded-t-3xl p-8"> <div class="bg-dark/10 mt-16 rounded-t-3xl p-8">
<div class="mx-auto max-w-(--breakpoint-lg) xl:py-8"> <div class="mx-auto max-w-(--breakpoint-lg) xl:py-8">
<Stack gap={8}> <Stack gap={8}>
<div class="place-center grid grid-cols-2 gap-8 md:grid-cols-3 lg:grid-cols-5"> <div class="place-center grid grid-cols-2 gap-8 md:grid-cols-3 lg:grid-cols-5">
<Stack> <Stack>
<Heading size="tiny">Social</Heading> <Heading size="tiny">Social</Heading>
<SiteFooterLink href={Constants.Socials.Github} icon={siGithub} text="GitHub" /> <SiteFooterLink href={Constants.Socials.Github} icon={siGithub} text="GitHub" />
<SiteFooterLink href={Constants.Socials.Discord} icon={siDiscord} text="Discord" /> <SiteFooterLink href={Constants.Socials.Discord} icon={siDiscord} text="Discord" />
<SiteFooterLink href={Constants.Socials.Reddit} icon={siReddit} text="Reddit" /> <SiteFooterLink href={Constants.Socials.Reddit} icon={siReddit} text="Reddit" />
</Stack> </Stack>
<Stack> <Stack>
<Heading size="tiny">Download</Heading> <Heading size="tiny">Download</Heading>
<SiteFooterLink href={Constants.Get.Android} icon={siAndroid} text="Android" /> <SiteFooterLink href={Constants.Get.Android} icon={siAndroid} text="Android" />
<SiteFooterLink href={Constants.Get.iOS} icon={siApple} text="iOS" /> <SiteFooterLink href={Constants.Get.iOS} icon={siApple} text="iOS" />
<SiteFooterLink <SiteFooterLink href={Constants.Get.DockerCompose} icon={mdiServerOutline} text="Server" />
href={Constants.Get.DockerCompose} </Stack>
icon={mdiServerOutline}
text="Server"
/>
</Stack>
<Stack> <Stack>
<Heading size="tiny">Company</Heading> <Heading size="tiny">Company</Heading>
<SiteFooterLink <SiteFooterLink href={Constants.Socials.Futo} icon={mdiOfficeBuildingOutline} text="FUTO" />
href={Constants.Socials.Futo} <SiteFooterLink href={Constants.Sites.Buy} icon={mdiKey} text="Purchase" />
icon={mdiOfficeBuildingOutline} <SiteFooterLink href={Constants.Sites.Store} icon={mdiShoppingOutline} text="Merch" />
text="FUTO" </Stack>
/>
<SiteFooterLink href={Constants.Sites.Buy} icon={mdiKey} text="Purchase" />
<SiteFooterLink href={Constants.Sites.Store} icon={mdiShoppingOutline} text="Merch" />
</Stack>
<Stack> <Stack>
<Heading size="tiny">Sites</Heading> <Heading size="tiny">Sites</Heading>
<SiteFooterLink <SiteFooterLink href={Constants.Sites.Docs} icon={mdiScriptTextOutline} text="Documentation" />
href={Constants.Sites.Docs} <SiteFooterLink href={Constants.Sites.My} icon={mdiHomeSearchOutline} text="My Immich" />
icon={mdiScriptTextOutline} <SiteFooterLink href={Constants.Sites.Api} icon={mdiCubeOutline} text="Immich API" />
text="Documentation" </Stack>
/>
<SiteFooterLink href={Constants.Sites.My} icon={mdiHomeSearchOutline} text="My Immich" />
<SiteFooterLink href={Constants.Sites.Api} icon={mdiCubeOutline} text="Immich API" />
</Stack>
<Stack> <Stack>
<Heading size="tiny">Miscellaneous</Heading> <Heading size="tiny">Miscellaneous</Heading>
<SiteFooterLink href={Constants.Pages.Roadmap} icon={mdiChartGantt} text="Roadmap" /> <SiteFooterLink href={Constants.Pages.Roadmap} icon={mdiChartGantt} text="Roadmap" />
<SiteFooterLink <SiteFooterLink href={Constants.Pages.CursedKnowledge} icon={mdiBookshelf} text="Cursed Knowledge" />
href={Constants.Pages.CursedKnowledge} </Stack>
icon={mdiBookshelf} </div>
text="Cursed Knowledge" <VStack class="text-center">
/> <Text size="large">This project is available under GNU AGPL v3 license.</Text>
</Stack> <Text color="muted" variant="italic">Privacy should not be a luxury</Text>
</div> <ThemeSwitcher color="secondary" />
<VStack class="text-center"> </VStack>
<Text size="large">This project is available under GNU AGPL v3 license.</Text> </Stack>
<Text color="muted" variant="italic">Privacy should not be a luxury</Text> </div>
<ThemeSwitcher color="secondary" />
</VStack>
</Stack>
</div>
</div> </div>
+14 -14
View File
@@ -1,21 +1,21 @@
<script lang="ts"> <script lang="ts">
import HStack from '$lib/components/Stack/HStack.svelte'; import HStack from '$lib/components/Stack/HStack.svelte';
import Icon from '$lib/components/Icon/Icon.svelte'; import Icon from '$lib/components/Icon/Icon.svelte';
import Link from '$lib/components/Link/Link.svelte'; import Link from '$lib/components/Link/Link.svelte';
import Text from '$lib/components/Text/Text.svelte'; import Text from '$lib/components/Text/Text.svelte';
type Props = { type Props = {
href: string; href: string;
icon: string | { path: string }; icon: string | { path: string };
text: string; text: string;
}; };
let { href, icon, text }: Props = $props(); let { href, icon, text }: Props = $props();
</script> </script>
<Link {href} external> <Link {href} external>
<HStack> <HStack>
<Icon {icon} size="1.5em" /> <Icon {icon} size="1.5em" />
<Text>{text}</Text> <Text>{text}</Text>
</HStack> </HStack>
</Link> </Link>
+113 -113
View File
@@ -2,122 +2,122 @@ import { asText } from '$lib/services/command-palette-manager.svelte.js';
import { mdiOpenInNew } from '@mdi/js'; import { mdiOpenInNew } from '@mdi/js';
export const Constants = { export const Constants = {
Socials: { Socials: {
Futo: 'https://futo.org/', Futo: 'https://futo.org/',
Github: 'https://github.com/immich-app/immich', Github: 'https://github.com/immich-app/immich',
Discord: 'https://discord.immich.app/', Discord: 'https://discord.immich.app/',
Reddit: 'https://www.reddit.com/r/immich/', Reddit: 'https://www.reddit.com/r/immich/',
Weblate: 'https://hosted.weblate.org/projects/immich/immich/', Weblate: 'https://hosted.weblate.org/projects/immich/immich/',
}, },
Get: { Get: {
iOS: 'https://get.immich.app/ios', iOS: 'https://get.immich.app/ios',
Android: 'https://get.immich.app/android', Android: 'https://get.immich.app/android',
FDroid: 'https://get.immich.app/fdroid', FDroid: 'https://get.immich.app/fdroid',
GithubRelease: 'https://github.com/immich-app/immich/releases/latest', GithubRelease: 'https://github.com/immich-app/immich/releases/latest',
DockerCompose: 'https://get.immich.app/docker-compose', DockerCompose: 'https://get.immich.app/docker-compose',
}, },
Sites: { Sites: {
Api: 'https://api.immich.app/', Api: 'https://api.immich.app/',
Demo: 'https://demo.immich.app/', Demo: 'https://demo.immich.app/',
My: 'https://my.immich.app/', My: 'https://my.immich.app/',
Buy: 'https://buy.immich.app/', Buy: 'https://buy.immich.app/',
Get: 'https://get.immich.app/', Get: 'https://get.immich.app/',
Docs: 'https://docs.immich.app/', Docs: 'https://docs.immich.app/',
Store: 'https://immich.store/', Store: 'https://immich.store/',
}, },
Pages: { Pages: {
CursedKnowledge: 'https://immich.app/cursed-knowledge', CursedKnowledge: 'https://immich.app/cursed-knowledge',
Roadmap: 'https://immich.app/roadmap', Roadmap: 'https://immich.app/roadmap',
}, },
Npm: { Npm: {
Sdk: 'https://www.npmjs.com/package/@immich/sdk', Sdk: 'https://www.npmjs.com/package/@immich/sdk',
}, },
}; };
export const siteCommands = [ export const siteCommands = [
{ {
title: 'Immich Documentation', title: 'Immich Documentation',
description: 'View the Immich documentation', description: 'View the Immich documentation',
href: Constants.Sites.Docs, href: Constants.Sites.Docs,
}, },
{ {
title: 'Buy Immich', title: 'Buy Immich',
description: 'Support Immich by buying a product key.', description: 'Support Immich by buying a product key.',
href: Constants.Sites.Buy, href: Constants.Sites.Buy,
}, },
{ {
title: 'My Immich', title: 'My Immich',
description: 'Immich link proxy to redirect to your personal instance', description: 'Immich link proxy to redirect to your personal instance',
href: Constants.Sites.My, href: Constants.Sites.My,
}, },
{ {
title: 'Get Immich', title: 'Get Immich',
description: 'View downloads links for Immich apps and server', description: 'View downloads links for Immich apps and server',
href: Constants.Sites.Get, href: Constants.Sites.Get,
}, },
{ {
title: 'Immich on the PlayStore', title: 'Immich on the PlayStore',
description: 'View Immich on the Google Play Store', description: 'View Immich on the Google Play Store',
href: Constants.Get.Android, href: Constants.Get.Android,
}, },
{ {
title: 'Immich on the iOS App Store', title: 'Immich on the iOS App Store',
description: 'View Immich on the iOS App Store', description: 'View Immich on the iOS App Store',
href: Constants.Get.iOS, href: Constants.Get.iOS,
}, },
{ {
title: 'Immich Demo', title: 'Immich Demo',
description: 'Test out Immich with our public demo server', description: 'Test out Immich with our public demo server',
href: Constants.Sites.Demo, href: Constants.Sites.Demo,
}, },
{ {
title: 'Immich Store', title: 'Immich Store',
description: 'Support the project by purchasing Immich merchandise', description: 'Support the project by purchasing Immich merchandise',
href: Constants.Sites.Store, href: Constants.Sites.Store,
}, },
{ {
title: 'Cursed Knowledge', title: 'Cursed Knowledge',
description: 'View our collection of cursed knowledge', description: 'View our collection of cursed knowledge',
href: Constants.Pages.CursedKnowledge, href: Constants.Pages.CursedKnowledge,
}, },
{ {
title: 'Roadmap', title: 'Roadmap',
description: 'View our project roadmap', description: 'View our project roadmap',
href: Constants.Pages.Roadmap, href: Constants.Pages.Roadmap,
}, },
{ {
title: 'reddit', title: 'reddit',
description: 'Join the Immich community on reddit', description: 'Join the Immich community on reddit',
href: Constants.Socials.Reddit, href: Constants.Socials.Reddit,
}, },
{ {
title: 'GitHub', title: 'GitHub',
description: 'View our project on GitHub', description: 'View our project on GitHub',
href: Constants.Socials.Github, href: Constants.Socials.Github,
}, },
{ {
title: 'Discord', title: 'Discord',
description: 'Join the conversation on Discord', description: 'Join the conversation on Discord',
href: Constants.Socials.Discord, href: Constants.Socials.Discord,
}, },
{ {
title: 'Weblate', title: 'Weblate',
description: 'Support the project by translating Immich on Weblate', description: 'Support the project by translating Immich on Weblate',
href: Constants.Socials.Weblate, href: Constants.Socials.Weblate,
}, },
{ {
title: 'FUTO', title: 'FUTO',
description: 'Learn more about FUTO, the company behind Immich', description: 'Learn more about FUTO, the company behind Immich',
href: Constants.Sites.Docs, href: Constants.Sites.Docs,
}, },
].map((site) => ({ ].map((site) => ({
icon: mdiOpenInNew, icon: mdiOpenInNew,
type: 'Link', type: 'Link',
iconClass: 'text-indigo-700 dark:text-indigo-200', iconClass: 'text-indigo-700 dark:text-indigo-200',
title: site.title, title: site.title,
description: site.description, description: site.description,
href: site.href, href: site.href,
external: true, external: true,
text: asText('Site', 'Link', site.title, site.description, site.href), text: asText('Site', 'Link', site.title, site.description, site.href),
})); }));
+21 -21
View File
@@ -1,25 +1,25 @@
import { tv } from 'tailwind-variants'; import { tv } from 'tailwind-variants';
export const styles = { export const styles = {
label: tv({ label: tv({
base: '', base: '',
variants: { variants: {
size: { size: {
tiny: 'text-xs', tiny: 'text-xs',
small: 'text-sm', small: 'text-sm',
medium: 'text-base', medium: 'text-base',
large: 'text-lg', large: 'text-lg',
giant: 'text-xl', giant: 'text-xl',
}, },
color: { color: {
muted: 'text-gray-600 dark:text-gray-400', muted: 'text-gray-600 dark:text-gray-400',
primary: 'text-primary', primary: 'text-primary',
secondary: 'text-dark', secondary: 'text-dark',
success: 'text-success', success: 'text-success',
danger: 'text-danger', danger: 'text-danger',
warning: 'text-warning', warning: 'text-warning',
info: 'text-info', info: 'text-info',
}, },
}, },
}), }),
}; };
+40 -40
View File
@@ -1,53 +1,53 @@
@import 'tailwindcss'; @import 'tailwindcss';
@theme inline { @theme inline {
--color-primary: rgb(var(--immich-ui-primary)); --color-primary: rgb(var(--immich-ui-primary));
--color-light: rgb(var(--immich-ui-light)); --color-light: rgb(var(--immich-ui-light));
--color-dark: rgb(var(--immich-ui-dark)); --color-dark: rgb(var(--immich-ui-dark));
--color-success: rgb(var(--immich-ui-success)); --color-success: rgb(var(--immich-ui-success));
--color-danger: rgb(var(--immich-ui-danger)); --color-danger: rgb(var(--immich-ui-danger));
--color-warning: rgb(var(--immich-ui-warning)); --color-warning: rgb(var(--immich-ui-warning));
--color-info: rgb(var(--immich-ui-info)); --color-info: rgb(var(--immich-ui-info));
--color-subtle: rgb(var(--immich-ui-gray)); --color-subtle: rgb(var(--immich-ui-gray));
} }
@custom-variant dark (&:where(.dark, .dark *):not(.light)); @custom-variant dark (&:where(.dark, .dark *):not(.light));
@layer base { @layer base {
:root, :root,
.light { .light {
/* light */ /* light */
--immich-ui-primary: 66 80 175; --immich-ui-primary: 66 80 175;
--immich-ui-dark: 20 22 26; --immich-ui-dark: 20 22 26;
--immich-ui-light: 255 255 255; --immich-ui-light: 255 255 255;
--immich-ui-success: 16 188 99; --immich-ui-success: 16 188 99;
--immich-ui-danger: 200 60 60; --immich-ui-danger: 200 60 60;
--immich-ui-warning: 216 143 64; --immich-ui-warning: 216 143 64;
--immich-ui-info: 8 111 230; --immich-ui-info: 8 111 230;
--immich-ui-gray: 246 246 246; --immich-ui-gray: 246 246 246;
--immich-ui-default-border: 209 213 219; --immich-ui-default-border: 209 213 219;
} }
.dark { .dark {
/* dark */ /* dark */
--immich-ui-primary: 172 203 250; --immich-ui-primary: 172 203 250;
--immich-ui-light: 0 0 0; --immich-ui-light: 0 0 0;
--immich-ui-dark: 229 231 235; --immich-ui-dark: 229 231 235;
--immich-ui-success: 72 237 152; --immich-ui-success: 72 237 152;
--immich-ui-danger: 246 125 125; --immich-ui-danger: 246 125 125;
--immich-ui-warning: 254 197 132; --immich-ui-warning: 254 197 132;
--immich-ui-info: 121 183 254; --immich-ui-info: 121 183 254;
--immich-ui-gray: 33 33 33; --immich-ui-gray: 33 33 33;
--immich-ui-default-border: 33 33 33; --immich-ui-default-border: 33 33 33;
} }
*, *,
::after, ::after,
::before, ::before,
::backdrop, ::backdrop,
::file-selector-button { ::file-selector-button {
border-color: rgb(var(--immich-ui-default-border)); border-color: rgb(var(--immich-ui-default-border));
} }
} }
+93 -93
View File
@@ -1,11 +1,11 @@
import type { Translations } from '$lib/services/translation.svelte.js'; import type { Translations } from '$lib/services/translation.svelte.js';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { import type {
HTMLAnchorAttributes, HTMLAnchorAttributes,
HTMLButtonAttributes, HTMLButtonAttributes,
HTMLInputAttributes, HTMLInputAttributes,
HTMLLabelAttributes, HTMLLabelAttributes,
HTMLTextareaAttributes, HTMLTextareaAttributes,
} from 'svelte/elements'; } from 'svelte/elements';
export type Color = 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info'; export type Color = 'primary' | 'secondary' | 'success' | 'danger' | 'warning' | 'info';
@@ -23,8 +23,8 @@ export type Variants = 'filled' | 'outline' | 'ghost';
export type Gap = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; export type Gap = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8;
export enum Theme { export enum Theme {
Light = 'light', Light = 'light',
Dark = 'dark', Dark = 'dark',
} }
export type TranslationProps<T extends keyof Translations> = { [K in T]?: string }; export type TranslationProps<T extends keyof Translations> = { [K in T]?: string };
@@ -32,145 +32,145 @@ export type TranslationProps<T extends keyof Translations> = { [K in T]?: string
export type IconLike = string | { path: string }; export type IconLike = string | { path: string };
export type IconProps = { export type IconProps = {
icon: IconLike; icon: IconLike;
title?: string; title?: string;
description?: string; description?: string;
size?: string; size?: string;
color?: Color | 'currentColor' | string; color?: Color | 'currentColor' | string;
flipped?: boolean; flipped?: boolean;
flopped?: boolean; flopped?: boolean;
spin?: boolean; spin?: boolean;
class?: string; class?: string;
viewBox?: string; viewBox?: string;
strokeWidth?: number; strokeWidth?: number;
strokeColor?: string; strokeColor?: string;
}; };
type ButtonOrAnchor = type ButtonOrAnchor =
| ({ href?: never; external?: never } & HTMLButtonAttributes) | ({ href?: never; external?: never } & HTMLButtonAttributes)
| ({ href: string; external?: boolean } & HTMLAnchorAttributes); | ({ href: string; external?: boolean } & HTMLAnchorAttributes);
type ButtonBase = { type ButtonBase = {
size?: Size; size?: Size;
variant?: Variants; variant?: Variants;
class?: string; class?: string;
color?: Color; color?: Color;
shape?: Shape; shape?: Shape;
}; };
export type ButtonProps = ButtonBase & { export type ButtonProps = ButtonBase & {
ref?: HTMLElement | null; ref?: HTMLElement | null;
fullWidth?: boolean; fullWidth?: boolean;
loading?: boolean; loading?: boolean;
leadingIcon?: IconLike; leadingIcon?: IconLike;
trailingIcon?: IconLike; trailingIcon?: IconLike;
} & ButtonOrAnchor; } & ButtonOrAnchor;
export type CloseButtonProps = { export type CloseButtonProps = {
size?: Size; size?: Size;
variant?: Variants; variant?: Variants;
class?: string; class?: string;
translations?: TranslationProps<'close'>; translations?: TranslationProps<'close'>;
} & ButtonOrAnchor; } & ButtonOrAnchor;
export type IconButtonProps = ButtonBase & { export type IconButtonProps = ButtonBase & {
icon: string; icon: string;
flipped?: boolean; flipped?: boolean;
flopped?: boolean; flopped?: boolean;
'aria-label': string; 'aria-label': string;
} & ButtonOrAnchor; } & ButtonOrAnchor;
type StackBaseProps = { type StackBaseProps = {
class?: string; class?: string;
children: Snippet; children: Snippet;
gap?: Gap; gap?: Gap;
wrap?: boolean; wrap?: boolean;
fullWidth?: boolean; fullWidth?: boolean;
fullHeight?: boolean; fullHeight?: boolean;
}; };
export type ChildData = { export type ChildData = {
snippet: Snippet; snippet: Snippet;
class?: string; class?: string;
}; };
export type StackProps = StackBaseProps & { export type StackProps = StackBaseProps & {
align?: 'start' | 'center' | 'end'; align?: 'start' | 'center' | 'end';
direction?: 'row' | 'column'; direction?: 'row' | 'column';
}; };
export type HStackProps = StackBaseProps; export type HStackProps = StackBaseProps;
export type VStackProps = StackBaseProps; export type VStackProps = StackBaseProps;
export type LabelProps = { export type LabelProps = {
label?: string; label?: string;
class?: string; class?: string;
size?: Size; size?: Size;
color?: TextColor; color?: TextColor;
children?: Snippet; children?: Snippet;
} & HTMLLabelAttributes; } & HTMLLabelAttributes;
export type FieldContext = { export type FieldContext = {
label?: string; label?: string;
description?: string; description?: string;
invalid?: boolean; invalid?: boolean;
disabled?: boolean; disabled?: boolean;
required?: boolean; required?: boolean;
readOnly?: boolean; readOnly?: boolean;
} & LabelProps; } & LabelProps;
type BaseInputProps = { type BaseInputProps = {
ref?: HTMLInputElement | null; ref?: HTMLInputElement | null;
class?: string; class?: string;
value?: string; value?: string;
size?: Size; size?: Size;
shape?: Shape; shape?: Shape;
inputSize?: HTMLInputAttributes['size']; inputSize?: HTMLInputAttributes['size'];
} & Omit<HTMLInputAttributes, 'size' | 'type'>; } & Omit<HTMLInputAttributes, 'size' | 'type'>;
export type InputProps = BaseInputProps & { export type InputProps = BaseInputProps & {
containerRef?: HTMLElement | null; containerRef?: HTMLElement | null;
type?: HTMLInputAttributes['type']; type?: HTMLInputAttributes['type'];
leadingIcon?: IconLike | Snippet; leadingIcon?: IconLike | Snippet;
trailingIcon?: IconLike | Snippet; trailingIcon?: IconLike | Snippet;
trailingText?: string; trailingText?: string;
}; };
export type PasswordInputProps = BaseInputProps & { export type PasswordInputProps = BaseInputProps & {
ref?: HTMLInputElement | null; ref?: HTMLInputElement | null;
translations?: TranslationProps<'show_password' | 'hide_password'>; translations?: TranslationProps<'show_password' | 'hide_password'>;
isVisible?: boolean; isVisible?: boolean;
}; };
export type TextareaProps = { export type TextareaProps = {
ref?: HTMLTextAreaElement | null; ref?: HTMLTextAreaElement | null;
containerRef?: HTMLElement | null; containerRef?: HTMLElement | null;
class?: string; class?: string;
value?: string; value?: string;
size?: Size; size?: Size;
shape?: Shape; shape?: Shape;
grow?: boolean; grow?: boolean;
} & HTMLTextareaAttributes; } & HTMLTextareaAttributes;
export type SelectItem = { export type SelectItem = {
label?: string; label?: string;
value: string; value: string;
disabled?: boolean; disabled?: boolean;
}; };
export type SelectCommonProps<T extends SelectItem> = { export type SelectCommonProps<T extends SelectItem> = {
data: string[] | T[]; data: string[] | T[];
size?: Size; size?: Size;
shape?: Shape; shape?: Shape;
placeholder?: string; placeholder?: string;
class?: string; class?: string;
}; };
export type SelectProps<T extends SelectItem> = SelectCommonProps<T> & { export type SelectProps<T extends SelectItem> = SelectCommonProps<T> & {
value?: T; value?: T;
onChange?: (value: T) => void; onChange?: (value: T) => void;
}; };
export type MultiSelectProps<T extends SelectItem> = SelectCommonProps<T> & { export type MultiSelectProps<T extends SelectItem> = SelectCommonProps<T> & {
values?: T[]; values?: T[];
onChange?: (values: T[]) => void; onChange?: (values: T[]) => void;
}; };
+14 -25
View File
@@ -1,22 +1,14 @@
export const enum ByteUnit { export const enum ByteUnit {
'B' = 'B', 'B' = 'B',
'KiB' = 'KiB', 'KiB' = 'KiB',
'MiB' = 'MiB', 'MiB' = 'MiB',
'GiB' = 'GiB', 'GiB' = 'GiB',
'TiB' = 'TiB', 'TiB' = 'TiB',
'PiB' = 'PiB', 'PiB' = 'PiB',
'EiB' = 'EiB', 'EiB' = 'EiB',
} }
const byteUnits = [ const byteUnits = [ByteUnit.B, ByteUnit.KiB, ByteUnit.MiB, ByteUnit.GiB, ByteUnit.TiB, ByteUnit.PiB, ByteUnit.EiB];
ByteUnit.B,
ByteUnit.KiB,
ByteUnit.MiB,
ByteUnit.GiB,
ByteUnit.TiB,
ByteUnit.PiB,
ByteUnit.EiB,
];
/** /**
* Convert bytes to best human readable unit and number of that unit. * Convert bytes to best human readable unit and number of that unit.
@@ -29,12 +21,9 @@ const byteUnits = [
* @returns size (number) and unit (string) * @returns size (number) and unit (string)
*/ */
export function getBytesWithUnit(bytes: number, maxPrecision = 1): [number, ByteUnit] { export function getBytesWithUnit(bytes: number, maxPrecision = 1): [number, ByteUnit] {
const magnitude = Math.floor(Math.log(bytes === 0 ? 1 : bytes) / Math.log(1024)); const magnitude = Math.floor(Math.log(bytes === 0 ? 1 : bytes) / Math.log(1024));
return [ return [Number.parseFloat((bytes / 1024 ** magnitude).toFixed(maxPrecision)), byteUnits[magnitude]];
Number.parseFloat((bytes / 1024 ** magnitude).toFixed(maxPrecision)),
byteUnits[magnitude],
];
} }
/** /**
@@ -49,8 +38,8 @@ export function getBytesWithUnit(bytes: number, maxPrecision = 1): [number, Byte
* @returns localized bytes with unit as string * @returns localized bytes with unit as string
*/ */
export function getByteUnitString(bytes: number, locale?: string, maxPrecision = 1): string { export function getByteUnitString(bytes: number, locale?: string, maxPrecision = 1): string {
const [size, unit] = getBytesWithUnit(bytes, maxPrecision); const [size, unit] = getBytesWithUnit(bytes, maxPrecision);
return `${size.toLocaleString(locale)} ${unit}`; return `${size.toLocaleString(locale)} ${unit}`;
} }
/** /**
@@ -63,7 +52,7 @@ export function getByteUnitString(bytes: number, locale?: string, maxPrecision =
* @returns bytes (number) * @returns bytes (number)
*/ */
export function convertToBytes(size: number, unit: ByteUnit): number { export function convertToBytes(size: number, unit: ByteUnit): number {
return size * 1024 ** byteUnits.indexOf(unit); return size * 1024 ** byteUnits.indexOf(unit);
} }
/** /**
@@ -76,5 +65,5 @@ export function convertToBytes(size: number, unit: ByteUnit): number {
* @returns bytes (number) * @returns bytes (number)
*/ */
export function convertFromBytes(bytes: number, unit: ByteUnit): number { export function convertFromBytes(bytes: number, unit: ByteUnit): number {
return bytes / 1024 ** byteUnits.indexOf(unit); return bytes / 1024 ** byteUnits.indexOf(unit);
} }
+11 -11
View File
@@ -2,17 +2,17 @@ import type { IconLike } from '$lib/types.js';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
export const cleanClass = (...classNames: unknown[]) => { export const cleanClass = (...classNames: unknown[]) => {
return twMerge( return twMerge(
classNames classNames
.filter((className) => { .filter((className) => {
if (!className || typeof className === 'boolean') { if (!className || typeof className === 'boolean') {
return false; return false;
} }
return typeof className === 'string'; return typeof className === 'string';
}) })
.join(' '), .join(' '),
); );
}; };
export const withPrefix = (key: string) => `immich-ui-${key}`; export const withPrefix = (key: string) => `immich-ui-${key}`;
@@ -21,5 +21,5 @@ let _count = 0;
export const generateId = (): string => `ui-id-${_count++}`; export const generateId = (): string => `ui-id-${_count++}`;
export const isIconLike = (icon: unknown): icon is IconLike => { export const isIconLike = (icon: unknown): icon is IconLike => {
return typeof icon === 'string' || !!(icon && typeof icon === 'object' && 'path' in icon); return typeof icon === 'string' || !!(icon && typeof icon === 'object' && 'path' in icon);
}; };
+120 -128
View File
@@ -1,145 +1,137 @@
<script lang="ts"> <script lang="ts">
import { beforeNavigate } from '$app/navigation'; import { beforeNavigate } from '$app/navigation';
import { page } from '$app/state'; import { page } from '$app/state';
import Navbar from '$docs/components/Navbar.svelte'; import Navbar from '$docs/components/Navbar.svelte';
import { componentGroups } from '$docs/constants.js'; import { componentGroups } from '$docs/constants.js';
import { asComponentHref } from '$docs/utilities.js'; import { asComponentHref } from '$docs/utilities.js';
import { import {
AppShell, AppShell,
AppShellHeader, AppShellHeader,
AppShellSidebar, AppShellSidebar,
asText, asText,
CommandPalette, CommandPalette,
commandPaletteManager, commandPaletteManager,
IconButton, IconButton,
initializeTheme, initializeTheme,
Input, Input,
NavbarGroup, NavbarGroup,
NavbarItem, NavbarItem,
siteCommands, siteCommands,
SiteFooter, SiteFooter,
theme, theme,
ThemeSwitcher, ThemeSwitcher,
toggleTheme, toggleTheme,
} from '@immich/ui'; } from '@immich/ui';
import '@immich/ui/theme/default.css'; import '@immich/ui/theme/default.css';
import { import { mdiHome, mdiHomeOutline, mdiMagnify, mdiSlashForwardBox, mdiThemeLightDark } from '@mdi/js';
mdiHome, import { MediaQuery } from 'svelte/reactivity';
mdiHomeOutline, import '../app.css';
mdiMagnify,
mdiSlashForwardBox,
mdiThemeLightDark,
} from '@mdi/js';
import { MediaQuery } from 'svelte/reactivity';
import '../app.css';
initializeTheme(); initializeTheme();
let { children } = $props(); let { children } = $props();
const sidebar = new MediaQuery(`min-width: 850px`); const sidebar = new MediaQuery(`min-width: 850px`);
let open = $derived(sidebar.current); let open = $derived(sidebar.current);
beforeNavigate(() => { beforeNavigate(() => {
if (!sidebar.current) { if (!sidebar.current) {
open = false; open = false;
} }
}); });
commandPaletteManager.reset(); commandPaletteManager.reset();
// links // links
commandPaletteManager.addCommands(siteCommands); commandPaletteManager.addCommands(siteCommands);
// commands // commands
commandPaletteManager.addCommands([ commandPaletteManager.addCommands([
{ {
icon: mdiThemeLightDark, icon: mdiThemeLightDark,
iconClass: 'text-gray-700 dark:text-gray-200', iconClass: 'text-gray-700 dark:text-gray-200',
type: 'Command', type: 'Command',
title: 'Toggle theme', title: 'Toggle theme',
description: 'Switch between light and dark theme', description: 'Switch between light and dark theme',
action: () => toggleTheme(), action: () => toggleTheme(),
text: asText('Command', 'light', 'dark', 'theme', 'toggle'), text: asText('Command', 'light', 'dark', 'theme', 'toggle'),
}, },
]); ]);
// components // components
for (const group of componentGroups) { for (const group of componentGroups) {
commandPaletteManager.addCommands( commandPaletteManager.addCommands(
group.components.map((component) => ({ group.components.map((component) => ({
icon: component.icon, icon: component.icon,
iconClass: '', iconClass: '',
type: 'Component', type: 'Component',
title: component.name, title: component.name,
description: `View the ${component.name} component`, description: `View the ${component.name} component`,
href: asComponentHref(component.name), href: asComponentHref(component.name),
text: asText('Component', group.title, component.name), text: asText('Component', group.title, component.name),
})), })),
); );
} }
commandPaletteManager.enable(); commandPaletteManager.enable();
</script> </script>
<AppShell> <AppShell>
<AppShellHeader> <AppShellHeader>
<Navbar theme={theme.value} onToggleSidebar={() => (open = !open)}> <Navbar theme={theme.value} onToggleSidebar={() => (open = !open)}>
{#if commandPaletteManager.isEnabled} {#if commandPaletteManager.isEnabled}
<div class="hidden max-w-40 place-items-center p-1 lg:flex"> <div class="hidden max-w-40 place-items-center p-1 lg:flex">
<Input <Input
onfocus={() => commandPaletteManager.open()} onfocus={() => commandPaletteManager.open()}
leadingIcon={mdiMagnify} leadingIcon={mdiMagnify}
placeholder="Search..." placeholder="Search..."
class="bg-subtle! rounded-full border px-2 py-2" class="bg-subtle! rounded-full border px-2 py-2"
trailingIcon={mdiSlashForwardBox} trailingIcon={mdiSlashForwardBox}
/> />
</div> </div>
<IconButton <IconButton
icon={mdiMagnify} icon={mdiMagnify}
shape="round" shape="round"
variant="ghost" variant="ghost"
color="secondary" color="secondary"
aria-label="Search" aria-label="Search"
class="lg:hidden" class="lg:hidden"
onclick={() => commandPaletteManager.open()} onclick={() => commandPaletteManager.open()}
/> />
{/if} {/if}
<ThemeSwitcher size="medium" /> <ThemeSwitcher size="medium" />
</Navbar> </Navbar>
</AppShellHeader> </AppShellHeader>
<AppShellSidebar bind:open> <AppShellSidebar bind:open>
<div class="my-4 me-4"> <div class="my-4 me-4">
<NavbarItem <NavbarItem
title="Home" title="Home"
icon={mdiHomeOutline} icon={mdiHomeOutline}
activeIcon={mdiHome} activeIcon={mdiHome}
href="/" href="/"
isActive={() => page.url.pathname === '/'} isActive={() => page.url.pathname === '/'}
/> />
{#each componentGroups as group (group.title)} {#each componentGroups as group (group.title)}
<NavbarGroup title={group.title} /> <NavbarGroup title={group.title} />
{#each group.components as component (component.name)} {#each group.components as component (component.name)}
<NavbarItem <NavbarItem
href={asComponentHref(component.name)} href={asComponentHref(component.name)}
title={component.name} title={component.name}
icon={component.icon} icon={component.icon}
activeIcon={component.activeIcon} activeIcon={component.activeIcon}
/> />
{/each} {/each}
{/each} {/each}
</div> </div>
</AppShellSidebar> </AppShellSidebar>
<section class="flex h-full flex-col"> <section class="flex h-full flex-col">
<div class="grow"> <div class="grow">
{@render children?.()} {@render children?.()}
</div> </div>
<SiteFooter /> <SiteFooter />
</section> </section>
</AppShell> </AppShell>
<CommandPalette <CommandPalette translations={{ command_palette_prompt_default: 'Quickly find components, links, and commands' }} />
translations={{ command_palette_prompt_default: 'Quickly find components, links, and commands' }}
/>
+27 -28
View File
@@ -1,35 +1,34 @@
<script lang="ts"> <script lang="ts">
import ComponentCard from '$docs/components/ComponentCard.svelte'; import ComponentCard from '$docs/components/ComponentCard.svelte';
import Grid from '$docs/components/Grid.svelte'; import Grid from '$docs/components/Grid.svelte';
import { componentGroups } from '$docs/constants.js'; import { componentGroups } from '$docs/constants.js';
import { Code, Container, Heading, Link, Stack, Text } from '@immich/ui'; import { Code, Container, Heading, Link, Stack, Text } from '@immich/ui';
</script> </script>
<Container size="large" class="p-2"> <Container size="large" class="p-2">
<Stack class="flex flex-col gap-4 px-8 py-8" gap={8}> <Stack class="flex flex-col gap-4 px-8 py-8" gap={8}>
<Heading tag="h1" size="title">@immich/ui</Heading> <Heading tag="h1" size="title">@immich/ui</Heading>
<Text> <Text>
@immich/ui is a collection of <Link href="https://svelte.dev">Svelte</Link> components that are @immich/ui is a collection of <Link href="https://svelte.dev">Svelte</Link> components that are shared across all Immich
shared across all Immich projects. It is designed to be a simple and easy-to-use library that provides projects. It is designed to be a simple and easy-to-use library that provides a consistent look and feel.
a consistent look and feel. </Text>
</Text>
<Heading tag="h2" size="large">Install</Heading> <Heading tag="h2" size="large">Install</Heading>
<Text>@immich/ui is published as an npm package. You can install it using npm.</Text> <Text>@immich/ui is published as an npm package. You can install it using npm.</Text>
<Code color="primary" variant="filled">npm i --save-dev @immich/ui</Code> <Code color="primary" variant="filled">npm i --save-dev @immich/ui</Code>
<Heading tag="h2" size="large">Components</Heading> <Heading tag="h2" size="large">Components</Heading>
<Stack gap={8}> <Stack gap={8}>
{#each componentGroups as group (group.title)} {#each componentGroups as group (group.title)}
<Stack> <Stack>
<Heading tag="h3" size="medium" class="mb-4">{group.title}</Heading> <Heading tag="h3" size="medium" class="mb-4">{group.title}</Heading>
<Grid> <Grid>
{#each group.components as component (component.name)} {#each group.components as component (component.name)}
<ComponentCard {component} /> <ComponentCard {component} />
{/each} {/each}
</Grid> </Grid>
</Stack> </Stack>
{/each} {/each}
</Stack> </Stack>
</Stack> </Stack>
</Container> </Container>
+18 -18
View File
@@ -1,23 +1,23 @@
<script lang="ts"> <script lang="ts">
import ComponentExamples from '$docs/components/ComponentExamples.svelte'; import ComponentExamples from '$docs/components/ComponentExamples.svelte';
import ComponentPage from '$docs/components/ComponentPage.svelte'; import ComponentPage from '$docs/components/ComponentPage.svelte';
import BasicExample from './BasicExample.svelte'; import BasicExample from './BasicExample.svelte';
import basicExample from './BasicExample.svelte?raw'; import basicExample from './BasicExample.svelte?raw';
import ColorExample from './ColorExample.svelte'; import ColorExample from './ColorExample.svelte';
import colorExample from './ColorExample.svelte?raw'; import colorExample from './ColorExample.svelte?raw';
import CustomIconExample from './CustomIconExample.svelte'; import CustomIconExample from './CustomIconExample.svelte';
import customIconExample from './CustomIconExample.svelte?raw'; import customIconExample from './CustomIconExample.svelte?raw';
import EventExample from './EventExample.svelte'; import EventExample from './EventExample.svelte';
import eventExample from './EventExample.svelte?raw'; import eventExample from './EventExample.svelte?raw';
</script> </script>
<ComponentPage name="Alert"> <ComponentPage name="Alert">
<ComponentExamples <ComponentExamples
examples={[ examples={[
{ title: 'Basic', code: basicExample, component: BasicExample }, { title: 'Basic', code: basicExample, component: BasicExample },
{ title: 'Color', code: colorExample, component: ColorExample }, { title: 'Color', code: colorExample, component: ColorExample },
{ title: 'Custom Icon', code: customIconExample, component: CustomIconExample }, { title: 'Custom Icon', code: customIconExample, component: CustomIconExample },
{ title: 'Event', code: eventExample, component: EventExample }, { title: 'Event', code: eventExample, component: EventExample },
]} ]}
/> />
</ComponentPage> </ComponentPage>

Some files were not shown because too many files have changed in this diff Show More