mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 12:06:42 +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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue