feat: modernize ui (#381)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Kyle Mendell
2025-03-30 13:19:14 -05:00
committed by GitHub
parent 5dcf69e974
commit 9881a1df9e
28 changed files with 847 additions and 512 deletions

View File

@@ -1,23 +1,25 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils/style';
import { LucideChevronDown } from 'lucide-svelte';
import { LucideChevronDown, type Icon as IconType } from 'lucide-svelte';
import { onMount, type Snippet } from 'svelte';
import { slide } from 'svelte/transition';
import { Button } from './ui/button';
import * as Card from './ui/card';
import { m } from '$lib/paraglide/messages';
let {
id,
title,
description,
defaultExpanded = false,
icon,
children
}: {
id: string;
title: string;
description?: string;
defaultExpanded?: boolean;
icon?: typeof IconType;
children: Snippet;
} = $props();
@@ -51,7 +53,12 @@
<Card.Header class="cursor-pointer" onclick={toggleExpanded}>
<div class="flex items-center justify-between">
<div>
<Card.Title>{title}</Card.Title>
<Card.Title class="flex items-center gap-2 text-xl font-semibold">
{#if icon}{@const Icon = icon}
<Icon class="text-primary/80 h-5 w-5" />
{/if}
{title}
</Card.Title>
{#if description}
<Card.Description>{description}</Card.Description>
{/if}
@@ -68,7 +75,7 @@
</Card.Header>
{#if expanded}
<div transition:slide={{ duration: 200 }}>
<Card.Content>
<Card.Content class="bg-muted/20 pt-5">
{@render children()}
</Card.Content>
</div>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import { page } from '$app/state';
import type { Snippet } from 'svelte';
let {
delay = 50,
stagger = 150,
children
}: {
delay?: number;
stagger?: number;
children: Snippet;
} = $props();
let containerNode: HTMLElement;
$effect(() => {
page.route;
applyAnimationDelays();
});
function applyAnimationDelays() {
if (containerNode) {
const childNodes = Array.from(containerNode.children);
childNodes.forEach((child, index) => {
// Skip comment nodes and text nodes
if (child.nodeType === 1) {
const itemDelay = delay + index * stagger;
(child as HTMLElement).style.setProperty('animation-delay', `${itemDelay}ms`);
console.log(itemDelay);
}
});
}
}
</script>
<svelte:head>
<style>
/* Base styles */
.fade-wrapper {
display: contents;
overflow: hidden;
}
/* Apply these styles to all children */
.fade-wrapper > * {
animation-fill-mode: both;
opacity: 0;
transform: translateY(10px);
animation-delay: calc(var(--animation-delay, 0ms) + 0.1s);
animation: fadeIn 0.8s ease-out forwards;
will-change: opacity, transform;
}
</style>
</svelte:head>
<div class="fade-wrapper" bind:this={containerNode}>
{@render children()}
</div>

View File

@@ -5,6 +5,7 @@
import { LucideLoader, LucideRefreshCw, LucideUpload } from 'lucide-svelte';
import { openConfirmDialog } from '../confirm-dialog';
import { m } from '$lib/paraglide/messages';
import type UserService from '$lib/services/user-service';
let {
userId,
@@ -34,7 +35,7 @@
reader.readAsDataURL(file);
await updateCallback(file).catch(() => {
imageDataURL = `/api/users/${userId}/profile-picture.png}`;
imageDataURL = `/api/users/${userId}/profile-picture.png`;
});
isLoading = false;
}
@@ -55,62 +56,53 @@
}
</script>
<div class="flex gap-5">
<div class="flex w-full flex-col justify-between gap-5 sm:flex-row">
<div>
<h3 class="text-xl font-semibold">{m.profile_picture()}</h3>
{#if isLdapUser}
<p class="text-muted-foreground mt-1 text-sm">
{m.profile_picture_is_managed_by_ldap_server()}
</p>
{:else}
<p class="text-muted-foreground mt-1 text-sm">
{m.click_profile_picture_to_upload_custom()}
</p>
<p class="text-muted-foreground mt-1 text-sm">{m.image_should_be_in_format()}</p>
{/if}
<Button
variant="outline"
size="sm"
class="mt-5"
on:click={onReset}
disabled={isLoading || isLdapUser}
>
<LucideRefreshCw class="mr-2 h-4 w-4" />
{m.reset_to_default()}
</Button>
</div>
<div class="flex flex-col items-center gap-6 sm:flex-row">
<div class="shrink-0">
{#if isLdapUser}
<Avatar.Root class="h-24 w-24">
<Avatar.Image class="object-cover" src={imageDataURL} />
</Avatar.Root>
{:else}
<div class="flex flex-col items-center gap-2">
<FileInput
id="profile-picture-input"
variant="secondary"
accept="image/png, image/jpeg"
onchange={onImageChange}
>
<div class="group relative h-28 w-28 rounded-full">
<Avatar.Root class="h-full w-full transition-opacity duration-200">
<Avatar.Image
class="object-cover group-hover:opacity-10 {isLoading ? 'opacity-10' : ''}"
src={imageDataURL}
/>
</Avatar.Root>
<div class="absolute inset-0 flex items-center justify-center">
{#if isLoading}
<LucideLoader class="h-5 w-5 animate-spin" />
{:else}
<LucideUpload
class="h-5 w-5 opacity-0 transition-opacity group-hover:opacity-100"
/>
{/if}
</div>
<FileInput
id="profile-picture-input"
variant="secondary"
accept="image/png, image/jpeg"
onchange={onImageChange}
>
<div class="group relative h-24 w-24 rounded-full">
<Avatar.Root class="h-full w-full transition-opacity duration-200">
<Avatar.Image
class="object-cover group-hover:opacity-30 {isLoading ? 'opacity-30' : ''}"
src={imageDataURL}
/>
</Avatar.Root>
<div class="absolute inset-0 flex items-center justify-center">
{#if isLoading}
<LucideLoader class="h-5 w-5 animate-spin" />
{:else}
<LucideUpload class="h-5 w-5 opacity-0 transition-opacity group-hover:opacity-100" />
{/if}
</div>
</FileInput>
</div>
</div>
</FileInput>
{/if}
</div>
<div class="grow">
<h3 class="font-medium">{m.profile_picture()}</h3>
{#if isLdapUser}
<p class="text-muted-foreground text-sm">
{m.profile_picture_is_managed_by_ldap_server()}
</p>
{:else}
<p class="text-muted-foreground text-sm">
{m.click_profile_picture_to_upload_custom()}
</p>
<p class="text-muted-foreground mb-2 text-sm">{m.image_should_be_in_format()}</p>
<Button variant="outline" size="sm" on:click={onReset} disabled={isLoading || isLdapUser}>
<LucideRefreshCw class="mr-2 h-4 w-4" />
{m.reset_to_default()}
</Button>
{/if}
</div>
</div>

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '$lib/components/ui/tooltip';
import { m } from '$lib/paraglide/messages';
import { LucideCalendar, LucidePencil, LucideTrash, type Icon as IconType } from 'lucide-svelte';
let {
icon,
onRename,
onDelete,
label,
description
}: {
icon: typeof IconType;
onRename: () => void;
onDelete: () => void;
description?: string;
label?: string;
} = $props();
</script>
<div class="bg-card hover:bg-muted/50 group rounded-lg p-3 transition-colors">
<div class="flex items-center justify-between">
<div class="flex items-start gap-3">
<div class="bg-primary/10 text-primary mt-1 rounded-lg p-2">
{#if icon}{@const Icon = icon}
<Icon class="h-5 w-5" />
{/if}
</div>
<div>
<div class="flex items-center gap-2">
<p class="font-medium">{label}</p>
</div>
{#if description}
<div class="text-muted-foreground mt-1 flex items-center text-xs">
<LucideCalendar class="mr-1 h-3 w-3" />
{description}
</div>
{/if}
</div>
</div>
<div class="flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<Button
on:click={onRename}
size="icon"
variant="ghost"
class="h-8 w-8"
aria-label={m.rename()}
>
<LucidePencil class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{m.rename()}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
on:click={onDelete}
size="icon"
variant="ghost"
class="hover:bg-destructive/10 hover:text-destructive h-8 w-8"
aria-label={m.delete()}
>
<LucideTrash class="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{m.delete()}</TooltipContent>
</Tooltip>
</div>
</div>
</div>

View File

@@ -20,10 +20,15 @@
>
<div class="flex h-16 items-center">
{#if !isAuthPage}
<Logo class="mr-3 h-8 w-8" />
<h1 class="text-lg font-medium" data-testid="application-name">
{$appConfigStore.appName}
</h1>
<a
href="/settings/account"
class="flex items-center gap-3 transition-opacity hover:opacity-80"
>
<Logo class="h-8 w-8" />
<h1 class="text-lg font-semibold tracking-tight" data-testid="application-name">
{$appConfigStore.appName}
</h1>
</a>
{/if}
</div>
<div class="flex items-center justify-between gap-4">

View File

@@ -1,34 +1,46 @@
<script lang="ts">
import { page } from '$app/state';
import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils/style';
import type { Snippet } from 'svelte';
import * as Card from './ui/card';
import { m } from '$lib/paraglide/messages';
let {
children,
showAlternativeSignInMethodButton = false
showAlternativeSignInMethodButton = false,
animate = false
}: {
children: Snippet;
showAlternativeSignInMethodButton?: boolean;
animate?: boolean;
} = $props();
</script>
<!-- Desktop -->
<div class="hidden h-screen items-center text-center lg:flex">
<div class="h-full min-w-[650px] p-16 {showAlternativeSignInMethodButton ? 'pb-0' : ''}">
<div class="flex h-full flex-col">
<div class="flex flex-grow flex-col items-center justify-center">
<!-- Desktop with sliding reveal animation -->
<div class="hidden h-screen items-center overflow-hidden text-center lg:flex">
<!-- Content area that fades in after background slides -->
<div
class="relative z-10 flex h-full w-[650px] p-16 {cn(
showAlternativeSignInMethodButton && 'pb-0',
animate && 'animate-delayed-fade'
)}"
>
<div class="flex h-full w-full flex-col overflow-hidden">
<div class="relative flex flex-grow flex-col items-center justify-center overflow-auto">
{@render children()}
</div>
{#if showAlternativeSignInMethodButton}
<div class="mb-4 flex justify-center">
<div
class="mb-4 flex items-center justify-center"
style={animate ? 'animation-delay: 1000ms;' : ''}
>
<a
href={page.url.pathname == '/login'
? '/login/alternative'
: `/login/alternative?redirect=${encodeURIComponent(
page.url.pathname + page.url.search
)}`}
class="text-muted-foreground text-xs"
class="text-muted-foreground text-xs transition-colors hover:underline"
>
{m.dont_have_access_to_your_passkey()}
</a>
@@ -36,18 +48,22 @@
{/if}
</div>
</div>
<img
src="/api/application-configuration/background-image"
class="h-screen w-[calc(100vw-650px)] rounded-l-[60px] object-cover"
alt={m.login_background()}
/>
<!-- Background image with slide animation -->
<div class="{cn(animate && 'animate-slide-bg-container')} absolute bottom-0 right-0 top-0 z-0">
<img
src="/api/application-configuration/background-image"
class="h-screen rounded-l-[60px] object-cover {animate ? 'w-full' : 'w-[calc(100vw-650px)]'}"
alt={m.login_background()}
/>
</div>
</div>
<!-- Mobile -->
<div
class="flex h-screen items-center justify-center bg-[url('/api/application-configuration/background-image')] bg-cover bg-center text-center lg:hidden"
>
<Card.Root class="mx-3">
<Card.Root class="mx-3 w-full max-w-md" style={animate ? 'animation-delay: 200ms;' : ''}>
<Card.CardContent
class="px-4 py-10 sm:p-10 {showAlternativeSignInMethodButton ? 'pb-3 sm:pb-3' : ''}"
>
@@ -59,7 +75,7 @@
: `/login/alternative?redirect=${encodeURIComponent(
page.url.pathname + page.url.search
)}`}
class="text-muted-foreground mt-7 flex justify-center text-xs"
class="text-muted-foreground mt-7 flex justify-center text-xs transition-colors hover:underline"
>
{m.dont_have_access_to_your_passkey()}
</a>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/style.js';
import type { HTMLAttributes } from 'svelte/elements';
type $$Props = HTMLAttributes<HTMLDivElement>;
@@ -8,6 +8,6 @@
export { className as class };
</script>
<div class={cn('p-6 pt-0', className)} {...$$restProps}>
<div class={cn('bg-muted/20 p-6 pt-5 peer-[.card-header]:border-t', className)} {...$$restProps}>
<slot />
</div>

View File

@@ -8,6 +8,6 @@
export { className as class };
</script>
<p class={cn('text-sm text-muted-foreground mt-1', className)} {...$$restProps}>
<p class={cn('text-muted-foreground mt-1 text-sm', className)} {...$$restProps}>
<slot />
</p>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils/style.js';
import type { HTMLAttributes } from 'svelte/elements';
type $$Props = HTMLAttributes<HTMLDivElement>;
@@ -8,6 +8,6 @@
export { className as class };
</script>
<div class={cn('flex flex-col space-y-1.5 p-6', className)} {...$$restProps}>
<div class={cn('card-header peer flex flex-col space-y-1.5 p-6', className)} {...$$restProps}>
<slot />
</div>

View File

@@ -14,7 +14,7 @@
<svelte:element
this={tag}
class={cn('text-xl font-semibold leading-none tracking-tight', className)}
class={cn('flex items-center gap-2 text-xl font-semibold leading-none tracking-tight', className)}
{...$$restProps}
>
<slot />

View File

@@ -9,7 +9,7 @@
</script>
<div
class={cn('rounded-lg border bg-card text-card-foreground shadow-sm', className)}
class={cn('bg-card text-card-foreground overflow-hidden rounded-lg border shadow-sm', className)}
{...$$restProps}
>
<slot />