feat: add Tier 2 shared components (stats, tags, media)

New shared-ui components:
- GlassCard: Glassmorphism container for cards
- StatRow: Generic stat row with snippet-based icons
- TagBadge: Reusable tag badge component
- AudioPlayer: Full-featured audio player with customizable icons

Updated Memoro to use shared components as wrappers while
maintaining app-specific features (icons, styling).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-24 22:39:15 +01:00
parent f93cb997cf
commit 10325026f9
13 changed files with 513 additions and 304 deletions

View file

@ -1,256 +1,25 @@
<script lang="ts">
import { onMount } from 'svelte';
/**
* Memoro AudioPlayer
* Wrapper around shared AudioPlayer with Memoro's custom icons
*/
import { AudioPlayer } from '@manacore/shared-ui';
import Icon from '$lib/components/Icon.svelte';
import { Text } from '@manacore/shared-ui';
let { src, duration }: { src: string; duration?: number } = $props();
// Support both 'src' and 'audioUrl' props for compatibility
const audioUrl = src;
let audio: HTMLAudioElement;
let currentTime = $state(0);
let audioDuration = $state(duration || 0);
let isPlaying = $state(false);
let isLoading = $state(true);
let playbackRate = $state(1.0);
onMount(() => {
if (audio) {
audio.addEventListener('loadedmetadata', () => {
audioDuration = audio.duration;
isLoading = false;
});
audio.addEventListener('timeupdate', () => {
currentTime = audio.currentTime;
});
audio.addEventListener('ended', () => {
isPlaying = false;
currentTime = 0;
});
audio.addEventListener('error', () => {
isLoading = false;
});
}
});
function togglePlay() {
if (!audio) return;
if (isPlaying) {
audio.pause();
} else {
audio.play();
}
isPlaying = !isPlaying;
}
function seek(e: Event) {
if (!audio) return;
const input = e.target as HTMLInputElement;
audio.currentTime = parseFloat(input.value);
}
function changePlaybackRate() {
if (!audio) return;
const rates = [1.0, 1.25, 1.5, 1.75, 2.0];
const currentIndex = rates.indexOf(playbackRate);
const nextIndex = (currentIndex + 1) % rates.length;
playbackRate = rates[nextIndex];
audio.playbackRate = playbackRate;
}
function formatTime(seconds: number) {
if (isNaN(seconds)) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function skipForward() {
if (!audio) return;
audio.currentTime = Math.min(audio.currentTime + 10, audioDuration);
}
function skipBackward() {
if (!audio) return;
audio.currentTime = Math.max(audio.currentTime - 10, 0);
}
// Calculate progress percentage for styling
const progressPercent = $derived(
audioDuration > 0 ? (currentTime / audioDuration) * 100 : 0
);
</script>
<div class="rounded-2xl border border-theme bg-content p-4">
<audio bind:this={audio} src={audioUrl} preload="metadata"></audio>
<!-- Progress Bar -->
<div class="mb-4">
<input
type="range"
min="0"
max={audioDuration || 100}
value={currentTime}
oninput={seek}
class="audio-slider w-full"
style="--progress: {progressPercent}%"
disabled={isLoading}
/>
<div class="mt-1 flex justify-between">
<Text variant="muted">{formatTime(currentTime)}</Text>
<Text variant="muted">{formatTime(audioDuration)}</Text>
</div>
</div>
<!-- Controls -->
<div class="flex items-center justify-center gap-4">
<!-- Skip Backward -->
<button
onclick={skipBackward}
disabled={isLoading}
class="rounded-full bg-secondary-button p-2 transition-colors hover:bg-content-hover disabled:opacity-50"
title="Skip backward 10s"
>
<Icon name="skip-back" size={24} class="text-theme" />
</button>
<!-- Play/Pause -->
<button
onclick={togglePlay}
disabled={isLoading}
class="rounded-full bg-primary-button p-4 text-primary-button-text transition-colors hover:opacity-90 disabled:opacity-50"
title={isPlaying ? 'Pause' : 'Play'}
>
{#if isLoading}
<svg class="h-6 w-6 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else if isPlaying}
<Icon name="pause" size={24} />
{:else}
<Icon name="play" size={24} />
{/if}
</button>
<!-- Skip Forward -->
<button
onclick={skipForward}
disabled={isLoading}
class="rounded-full bg-secondary-button p-2 transition-colors hover:bg-content-hover disabled:opacity-50"
title="Skip forward 10s"
>
<Icon name="skip-forward" size={24} class="text-theme" />
</button>
<!-- Playback Speed -->
<button
onclick={changePlaybackRate}
disabled={isLoading}
class="rounded-lg bg-secondary-button px-3 py-1 text-sm font-medium text-theme transition-colors hover:bg-content-hover disabled:opacity-50"
title="Change playback speed"
>
{playbackRate}x
</button>
</div>
</div>
<style>
/* Custom Range Slider Styling */
.audio-slider {
-webkit-appearance: none;
appearance: none;
height: 8px;
border-radius: 4px;
background: transparent;
outline: none;
cursor: pointer;
position: relative;
}
.audio-slider:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Webkit (Chrome, Safari, Edge) - Track */
.audio-slider::-webkit-slider-runnable-track {
width: 100%;
height: 8px;
border-radius: 4px;
background: linear-gradient(
to right,
var(--color-primary-button) 0%,
var(--color-primary-button) var(--progress, 0%),
var(--color-border) var(--progress, 0%),
var(--color-border) 100%
);
}
/* Webkit - Thumb */
.audio-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-primary-button);
cursor: pointer;
border: 3px solid var(--color-content-bg);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
margin-top: -6px;
transition: transform 0.1s ease;
}
.audio-slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.audio-slider::-webkit-slider-thumb:active {
transform: scale(1.1);
}
/* Firefox - Track */
.audio-slider::-moz-range-track {
width: 100%;
height: 8px;
border-radius: 4px;
background: var(--color-border);
}
/* Firefox - Progress */
.audio-slider::-moz-range-progress {
height: 8px;
border-radius: 4px;
background: var(--color-primary-button);
}
/* Firefox - Thumb */
.audio-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--color-primary-button);
cursor: pointer;
border: 3px solid var(--color-content-bg);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: transform 0.1s ease;
}
.audio-slider::-moz-range-thumb:hover {
transform: scale(1.15);
}
.audio-slider::-moz-range-thumb:active {
transform: scale(1.1);
}
</style>
<AudioPlayer {src} {duration}>
{#snippet playIcon()}
<Icon name="play" size={24} />
{/snippet}
{#snippet pauseIcon()}
<Icon name="pause" size={24} />
{/snippet}
{#snippet skipBackIcon()}
<Icon name="skip-back" size={24} class="text-theme" />
{/snippet}
{#snippet skipForwardIcon()}
<Icon name="skip-forward" size={24} class="text-theme" />
{/snippet}
</AudioPlayer>

View file

@ -1,4 +1,9 @@
<script lang="ts">
/**
* Memoro TagBadge
* Re-exports from @manacore/shared-ui for backward compatibility
*/
import { TagBadge } from '@manacore/shared-ui';
import type { Tag } from '$lib/types/memo.types';
let {
@ -14,47 +19,6 @@
onRemove?: () => void;
onClick?: () => void;
} = $props();
// Get tag color from either style.color (new format) or color (old format)
const tagColor = tag.style?.color || tag.color || '#3b82f6';
function handleClick() {
if (clickable && onClick) {
onClick();
}
}
function handleRemove(e: Event) {
e.stopPropagation();
if (onRemove) {
onRemove();
}
}
</script>
<span
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium transition-all"
class:cursor-pointer={clickable}
class:hover:scale-105={clickable}
style="background-color: {tagColor}20; color: {tagColor}"
onclick={handleClick}
role={clickable ? 'button' : undefined}
tabindex={clickable ? 0 : undefined}
>
<!-- Color indicator dot -->
<div class="h-2 w-2 rounded-full" style="background-color: {tagColor}"></div>
<span>{tag.name || tag.text}</span>
{#if removable}
<button
onclick={handleRemove}
class="ml-1 rounded-full hover:bg-black/10 p-0.5 transition-colors"
title="Remove tag"
aria-label="Remove tag"
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</span>
<TagBadge {tag} {removable} {clickable} {onRemove} {onClick} />

View file

@ -1,21 +1,19 @@
<script lang="ts">
/**
* Memoro GlassCard
* Re-exports from @manacore/shared-ui for backward compatibility
*/
import { GlassCard } from '@manacore/shared-ui';
import type { Snippet } from 'svelte';
interface Props {
children: any;
children: Snippet;
class?: string;
}
let { children, class: className = '' }: Props = $props();
</script>
<div
class="relative overflow-hidden rounded-3xl border border-theme bg-black/[0.02] dark:bg-white/[0.02] p-6 shadow-lg {className}"
>
<GlassCard class={className}>
{@render children()}
</div>
<style>
/* Additional glassmorphism effect */
div {
backdrop-filter: blur(10px);
}
</style>
</GlassCard>

View file

@ -1,4 +1,9 @@
<script lang="ts">
/**
* Memoro StatRow
* Custom version with Memoro-specific icon set
* Note: shared-ui provides a generic StatRow with snippet-based icons
*/
import { Text } from '@manacore/shared-ui';
interface Props {

View file

@ -5,6 +5,15 @@ export { Text, Button, Badge, Card } from './atoms';
export { Toggle, Input, Select, Textarea, Checkbox } from './molecules';
export type { SelectOption } from './molecules';
// Stats
export { GlassCard, StatRow } from './molecules';
// Tags
export { TagBadge } from './molecules';
// Media
export { AudioPlayer } from './molecules';
// Organisms
export { Modal, AppSlider } from './organisms';
export type { AppItem } from './organisms';

View file

@ -4,3 +4,12 @@ export { default as Select } from './Select.svelte';
export { default as Textarea } from './Textarea.svelte';
export { default as Checkbox } from './Checkbox.svelte';
export type { SelectOption } from './Select.svelte';
// Stats components
export { GlassCard, StatRow } from './stats';
// Tag components
export { TagBadge } from './tags';
// Media components
export { AudioPlayer } from './media';

View file

@ -0,0 +1,296 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
import { Text } from '../../atoms';
interface Props {
/** Audio source URL */
src: string;
/** Optional known duration (seconds) */
duration?: number;
/** Custom play icon snippet */
playIcon?: Snippet;
/** Custom pause icon snippet */
pauseIcon?: Snippet;
/** Custom skip back icon snippet */
skipBackIcon?: Snippet;
/** Custom skip forward icon snippet */
skipForwardIcon?: Snippet;
}
let { src, duration, playIcon, pauseIcon, skipBackIcon, skipForwardIcon }: Props = $props();
let audio: HTMLAudioElement;
let currentTime = $state(0);
let audioDuration = $state(duration || 0);
let isPlaying = $state(false);
let isLoading = $state(true);
let playbackRate = $state(1.0);
onMount(() => {
if (audio) {
audio.addEventListener('loadedmetadata', () => {
audioDuration = audio.duration;
isLoading = false;
});
audio.addEventListener('timeupdate', () => {
currentTime = audio.currentTime;
});
audio.addEventListener('ended', () => {
isPlaying = false;
currentTime = 0;
});
audio.addEventListener('error', () => {
isLoading = false;
});
}
});
function togglePlay() {
if (!audio) return;
if (isPlaying) {
audio.pause();
} else {
audio.play();
}
isPlaying = !isPlaying;
}
function seek(e: Event) {
if (!audio) return;
const input = e.target as HTMLInputElement;
audio.currentTime = parseFloat(input.value);
}
function changePlaybackRate() {
if (!audio) return;
const rates = [1.0, 1.25, 1.5, 1.75, 2.0];
const currentIndex = rates.indexOf(playbackRate);
const nextIndex = (currentIndex + 1) % rates.length;
playbackRate = rates[nextIndex];
audio.playbackRate = playbackRate;
}
function formatTime(seconds: number) {
if (isNaN(seconds)) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function skipForward() {
if (!audio) return;
audio.currentTime = Math.min(audio.currentTime + 10, audioDuration);
}
function skipBackward() {
if (!audio) return;
audio.currentTime = Math.max(audio.currentTime - 10, 0);
}
// Calculate progress percentage for styling
const progressPercent = $derived(
audioDuration > 0 ? (currentTime / audioDuration) * 100 : 0
);
</script>
<div class="rounded-2xl border border-theme bg-content p-4">
<audio bind:this={audio} {src} preload="metadata"></audio>
<!-- Progress Bar -->
<div class="mb-4">
<input
type="range"
min="0"
max={audioDuration || 100}
value={currentTime}
oninput={seek}
class="audio-slider w-full"
style="--progress: {progressPercent}%"
disabled={isLoading}
/>
<div class="mt-1 flex justify-between">
<Text variant="muted">{formatTime(currentTime)}</Text>
<Text variant="muted">{formatTime(audioDuration)}</Text>
</div>
</div>
<!-- Controls -->
<div class="flex items-center justify-center gap-4">
<!-- Skip Backward -->
<button
onclick={skipBackward}
disabled={isLoading}
class="rounded-full bg-secondary-button p-2 transition-colors hover:bg-content-hover disabled:opacity-50"
type="button"
aria-label="Skip backward 10 seconds"
>
{#if skipBackIcon}
{@render skipBackIcon()}
{:else}
<svg class="h-6 w-6 text-theme" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12.066 11.2a1 1 0 000 1.6l5.334 4A1 1 0 0019 16V8a1 1 0 00-1.6-.8l-5.333 4zM4.066 11.2a1 1 0 000 1.6l5.334 4A1 1 0 0011 16V8a1 1 0 00-1.6-.8l-5.334 4z" />
</svg>
{/if}
</button>
<!-- Play/Pause -->
<button
onclick={togglePlay}
disabled={isLoading}
class="rounded-full bg-primary-button p-4 text-primary-button-text transition-colors hover:opacity-90 disabled:opacity-50"
type="button"
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{#if isLoading}
<svg class="h-6 w-6 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else if isPlaying}
{#if pauseIcon}
{@render pauseIcon()}
{:else}
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
{/if}
{:else}
{#if playIcon}
{@render playIcon()}
{:else}
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
{/if}
{/if}
</button>
<!-- Skip Forward -->
<button
onclick={skipForward}
disabled={isLoading}
class="rounded-full bg-secondary-button p-2 transition-colors hover:bg-content-hover disabled:opacity-50"
type="button"
aria-label="Skip forward 10 seconds"
>
{#if skipForwardIcon}
{@render skipForwardIcon()}
{:else}
<svg class="h-6 w-6 text-theme" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11.933 12.8a1 1 0 000-1.6L6.6 7.2A1 1 0 005 8v8a1 1 0 001.6.8l5.333-4zM19.933 12.8a1 1 0 000-1.6l-5.333-4A1 1 0 0013 8v8a1 1 0 001.6.8l5.333-4z" />
</svg>
{/if}
</button>
<!-- Playback Speed -->
<button
onclick={changePlaybackRate}
disabled={isLoading}
class="rounded-lg bg-secondary-button px-3 py-1 text-sm font-medium text-theme transition-colors hover:bg-content-hover disabled:opacity-50"
type="button"
aria-label="Change playback speed"
>
{playbackRate}x
</button>
</div>
</div>
<style>
/* Custom Range Slider Styling */
.audio-slider {
-webkit-appearance: none;
appearance: none;
height: 8px;
border-radius: 4px;
background: transparent;
outline: none;
cursor: pointer;
position: relative;
}
.audio-slider:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Webkit (Chrome, Safari, Edge) - Track */
.audio-slider::-webkit-slider-runnable-track {
width: 100%;
height: 8px;
border-radius: 4px;
background: linear-gradient(
to right,
var(--color-primary-button) 0%,
var(--color-primary-button) var(--progress, 0%),
var(--color-border) var(--progress, 0%),
var(--color-border) 100%
);
}
/* Webkit - Thumb */
.audio-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-primary-button);
cursor: pointer;
border: 3px solid var(--color-content-bg);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
margin-top: -6px;
transition: transform 0.1s ease;
}
.audio-slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.audio-slider::-webkit-slider-thumb:active {
transform: scale(1.1);
}
/* Firefox - Track */
.audio-slider::-moz-range-track {
width: 100%;
height: 8px;
border-radius: 4px;
background: var(--color-border);
}
/* Firefox - Progress */
.audio-slider::-moz-range-progress {
height: 8px;
border-radius: 4px;
background: var(--color-primary-button);
}
/* Firefox - Thumb */
.audio-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--color-primary-button);
cursor: pointer;
border: 3px solid var(--color-content-bg);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: transform 0.1s ease;
}
.audio-slider::-moz-range-thumb:hover {
transform: scale(1.15);
}
.audio-slider::-moz-range-thumb:active {
transform: scale(1.1);
}
</style>

View file

@ -0,0 +1 @@
export { default as AudioPlayer } from './AudioPlayer.svelte';

View file

@ -0,0 +1,25 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
/** Content to render inside the card */
children: Snippet;
/** Additional CSS classes */
class?: string;
}
let { children, class: className = '' }: Props = $props();
</script>
<div
class="glass-card relative overflow-hidden rounded-3xl border border-theme bg-black/[0.02] dark:bg-white/[0.02] p-6 shadow-lg {className}"
>
{@render children()}
</div>
<style>
/* Glassmorphism effect */
.glass-card {
backdrop-filter: blur(10px);
}
</style>

View file

@ -0,0 +1,39 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { Text } from '../../atoms';
interface Props {
/** Title/label for the stat */
title: string;
/** Value to display */
value: string;
/** Optional subtitle/description */
subtitle?: string;
/** Icon snippet - allows custom icon rendering */
icon?: Snippet;
}
let { title, value, subtitle, icon }: Props = $props();
</script>
<div
class="flex items-center gap-3 border border-theme bg-black/[0.03] dark:bg-white/[0.06] px-3 py-2.5 transition-colors hover:bg-black/[0.06] dark:hover:bg-white/[0.12] first:rounded-t-xl last:rounded-b-xl"
>
<!-- Icon slot -->
{#if icon}
<div class="flex-shrink-0 text-theme-secondary">
{@render icon()}
</div>
{/if}
<!-- Content -->
<div class="flex-1">
<Text variant="small" weight="medium">{title}</Text>
{#if subtitle}
<Text variant="muted" class="mt-0.5">{subtitle}</Text>
{/if}
</div>
<!-- Value -->
<Text variant="body" weight="bold" class="text-right">{value}</Text>
</div>

View file

@ -0,0 +1,2 @@
export { default as GlassCard } from './GlassCard.svelte';
export { default as StatRow } from './StatRow.svelte';

View file

@ -0,0 +1,91 @@
<script lang="ts">
/**
* Generic tag badge component
* Displays a colored badge with optional remove button
*/
interface TagData {
/** Tag display name */
name?: string;
/** Alternative name field (for compatibility) */
text?: string;
/** Tag color (hex) */
color?: string;
/** Nested style object with color */
style?: { color?: string };
}
interface Props {
/** Tag data object */
tag: TagData;
/** Show remove button */
removable?: boolean;
/** Enable click interaction */
clickable?: boolean;
/** Remove callback */
onRemove?: () => void;
/** Click callback */
onClick?: () => void;
}
let {
tag,
removable = false,
clickable = false,
onRemove,
onClick
}: Props = $props();
// Get tag color from either style.color (new format) or color (old format)
const tagColor = $derived(tag.style?.color || tag.color || '#3b82f6');
const tagName = $derived(tag.name || tag.text || '');
function handleClick() {
if (clickable && onClick) {
onClick();
}
}
function handleRemove(e: Event) {
e.stopPropagation();
if (onRemove) {
onRemove();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (clickable && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
handleClick();
}
}
</script>
<span
class="inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium transition-all"
class:cursor-pointer={clickable}
class:hover:scale-105={clickable}
style="background-color: {tagColor}20; color: {tagColor}"
onclick={handleClick}
onkeydown={handleKeyDown}
role={clickable ? 'button' : undefined}
tabindex={clickable ? 0 : -1}
>
<!-- Color indicator dot -->
<div class="h-2 w-2 rounded-full" style="background-color: {tagColor}"></div>
<span>{tagName}</span>
{#if removable}
<button
onclick={handleRemove}
class="ml-1 rounded-full hover:bg-black/10 p-0.5 transition-colors"
type="button"
aria-label="Remove tag"
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/if}
</span>

View file

@ -0,0 +1 @@
export { default as TagBadge } from './TagBadge.svelte';