mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
f93cb997cf
commit
10325026f9
13 changed files with 513 additions and 304 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
296
packages/shared-ui/src/molecules/media/AudioPlayer.svelte
Normal file
296
packages/shared-ui/src/molecules/media/AudioPlayer.svelte
Normal 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>
|
||||
1
packages/shared-ui/src/molecules/media/index.ts
Normal file
1
packages/shared-ui/src/molecules/media/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as AudioPlayer } from './AudioPlayer.svelte';
|
||||
25
packages/shared-ui/src/molecules/stats/GlassCard.svelte
Normal file
25
packages/shared-ui/src/molecules/stats/GlassCard.svelte
Normal 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>
|
||||
39
packages/shared-ui/src/molecules/stats/StatRow.svelte
Normal file
39
packages/shared-ui/src/molecules/stats/StatRow.svelte
Normal 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>
|
||||
2
packages/shared-ui/src/molecules/stats/index.ts
Normal file
2
packages/shared-ui/src/molecules/stats/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as GlassCard } from './GlassCard.svelte';
|
||||
export { default as StatRow } from './StatRow.svelte';
|
||||
91
packages/shared-ui/src/molecules/tags/TagBadge.svelte
Normal file
91
packages/shared-ui/src/molecules/tags/TagBadge.svelte
Normal 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>
|
||||
1
packages/shared-ui/src/molecules/tags/index.ts
Normal file
1
packages/shared-ui/src/molecules/tags/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as TagBadge } from './TagBadge.svelte';
|
||||
Loading…
Add table
Add a link
Reference in a new issue