synops/frontend/src/lib/components/traits/StudioTrait.svelte
vegard 543b0ca29f Mørkt tema på alle sider: workspace, canvas, blockshell, traits, collection
Erstattet alle hardkodede lyse farger (white, #f0f2f5, #f3f4f6)
med mørke (#0a0a0b, #1c1c20, #242428) i alle Svelte-komponenter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 02:27:15 +00:00

498 lines
11 KiB
Svelte

<script lang="ts">
import type { Node } from '$lib/realtime';
import { edgeStore, nodeStore, nodeVisibility } from '$lib/realtime';
import { setDragPayload, checkStudioCompat, type DragPayload } from '$lib/transfer';
import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types';
interface Props {
collection?: Node;
config: Record<string, unknown>;
userId?: string;
accessToken?: string;
/** Called when a drop is received on this panel */
onReceiveDrop?: (payload: DragPayload, intent: PlacementIntent) => void;
}
let { collection, config, userId, accessToken, onReceiveDrop }: Props = $props();
/**
* BlockReceiver implementation for Studio (Lydstudio).
* Only accepts audio files — opens them in the studio.
* Ref: docs/retninger/arbeidsflaten.md § Kompatibilitetsmatrise
*/
export const receiver: BlockReceiver = {
canReceive(payload: DragPayload) {
return checkStudioCompat(payload);
},
receive(payload: DragPayload) {
const intent: PlacementIntent = {
mode: 'lettvekts-triage',
contextId: collection?.id ?? '',
contextType: 'studio',
};
onReceiveDrop?.(payload, intent);
return intent;
}
};
// =========================================================================
// Audio nodes belonging to this collection
// =========================================================================
const audioNodes = $derived.by(() => {
const nodes: Node[] = [];
if (!collection) return nodes;
for (const edge of edgeStore.byTarget(collection.id)) {
if (edge.edgeType !== 'belongs_to') continue;
const node = nodeStore.get(edge.sourceId);
if (!node || nodeVisibility(node, userId) === 'hidden') continue;
if (node.nodeKind === 'media') {
try {
const meta = JSON.parse(node.metadata ?? '{}');
if (typeof meta.mime === 'string' && meta.mime.startsWith('audio/')) {
nodes.push(node);
}
} catch { /* skip */ }
}
}
nodes.sort((a, b) => {
const ta = a.createdAt ?? 0;
const tb = b.createdAt ?? 0;
return tb > ta ? 1 : tb < ta ? -1 : 0;
});
return nodes;
});
// =========================================================================
// Helpers
// =========================================================================
/** Check if a node has processed versions (derived_from edges) */
function hasVersions(nodeId: string): boolean {
for (const edge of edgeStore.byTarget(nodeId)) {
if (edge.edgeType === 'derived_from') return true;
}
return false;
}
/** Get version count for a node */
function versionCount(nodeId: string): number {
let count = 0;
for (const edge of edgeStore.byTarget(nodeId)) {
if (edge.edgeType === 'derived_from') count++;
}
return count;
}
/** Format duration from metadata (seconds → mm:ss) */
function formatDuration(node: Node): string {
try {
const meta = JSON.parse(node.metadata ?? '{}');
if (typeof meta.duration === 'number') {
const m = Math.floor(meta.duration / 60);
const s = Math.floor(meta.duration % 60);
return `${m}:${String(s).padStart(2, '0')}`;
}
} catch { /* skip */ }
return '';
}
/** Format file size from metadata */
function formatSize(node: Node): string {
try {
const meta = JSON.parse(node.metadata ?? '{}');
if (typeof meta.size === 'number') {
const kb = meta.size / 1024;
if (kb < 1024) return `${Math.round(kb)} KB`;
return `${(kb / 1024).toFixed(1)} MB`;
}
} catch { /* skip */ }
return '';
}
/** Get MIME subtype (e.g. 'wav' from 'audio/wav') */
function formatType(node: Node): string {
try {
const meta = JSON.parse(node.metadata ?? '{}');
if (typeof meta.mime === 'string') {
return meta.mime.replace('audio/', '').toUpperCase();
}
} catch { /* skip */ }
return '';
}
function formatTime(node: Node): string {
if (!node.createdAt) return '';
const ms = Math.floor(node.createdAt / 1000);
const date = new Date(ms);
return date.toLocaleDateString('nb-NO', { day: 'numeric', month: 'short' });
}
// =========================================================================
// Drag-and-drop: audio items as drag source
// =========================================================================
function handleDragStart(e: DragEvent, node: Node) {
if (!e.dataTransfer) return;
setDragPayload(e.dataTransfer, {
nodeId: node.id,
nodeKind: node.nodeKind,
sourcePanel: 'studio'
});
}
</script>
<!--
StudioTrait — fullverdig BlockShell-panel for lydstudio.
Viser lydfiler med metadata, drag-and-drop ut til andre paneler,
drop-aksept for lydfiler fra andre paneler, og lenke til studioet.
Forelder (collection page) wrapper dette i BlockShell.
-->
<div class="studio-trait">
{#if audioNodes.length === 0}
<div class="studio-empty">
<span class="studio-empty-icon">🎙️</span>
<p>Ingen lydfiler i denne samlingen.</p>
<p class="studio-empty-hint">Dra lydfiler hit fra andre paneler, eller last opp via studio.</p>
</div>
{:else}
<div class="studio-header">
<span class="studio-header-label">Lydfiler</span>
<span class="studio-header-count">{audioNodes.length}</span>
</div>
<div class="studio-items">
{#each audioNodes as node (node.id)}
{@const duration = formatDuration(node)}
{@const size = formatSize(node)}
{@const type = formatType(node)}
{@const versions = versionCount(node.id)}
{@const date = formatTime(node)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="studio-item"
draggable="true"
ondragstart={(e) => handleDragStart(e, node)}
>
<div class="studio-item-main">
<div class="studio-item-icon">🎵</div>
<div class="studio-item-text">
<span class="studio-item-title">{node.title ?? 'Uten tittel'}</span>
<div class="studio-item-meta">
{#if type}
<span class="studio-item-tag">{type}</span>
{/if}
{#if duration}
<span class="studio-item-duration">{duration}</span>
{/if}
{#if size}
<span class="studio-item-size">{size}</span>
{/if}
{#if date}
<span class="studio-item-date">{date}</span>
{/if}
</div>
</div>
<div class="studio-item-actions">
{#if versions > 0}
<span class="studio-item-versions" title="{versions} prosesserte versjon(er)">
{versions} ver.
</span>
{/if}
<a
href="/studio/{node.id}"
class="studio-edit-btn"
title="Åpne i lydstudio"
onclick={(e) => e.stopPropagation()}
>
Rediger
</a>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
/* ================================================================= */
/* Root — fills BlockShell content area */
/* ================================================================= */
.studio-trait {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
/* ================================================================= */
/* Empty state */
/* ================================================================= */
.studio-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 24px;
color: #5a5a66;
font-size: 13px;
text-align: center;
gap: 4px;
}
.studio-empty-icon {
font-size: 28px;
}
.studio-empty-hint {
font-size: 11px;
color: #d1d5db;
margin-top: 4px;
}
/* ================================================================= */
/* Header */
/* ================================================================= */
.studio-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
flex-shrink: 0;
border-bottom: 1px solid #f3f4f6;
}
.studio-header-label {
font-size: 11px;
color: #5a5a66;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.studio-header-count {
font-size: 11px;
color: #5a5a66;
background: #242428;
border-radius: 9999px;
padding: 0 6px;
line-height: 18px;
}
/* ================================================================= */
/* Items list */
/* ================================================================= */
.studio-items {
flex: 1;
overflow-y: auto;
padding: 6px;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 0;
}
.studio-item {
background: #1c1c20;
border: 1px solid #2a2a2e;
border-radius: 6px;
padding: 8px 10px;
cursor: grab;
transition: box-shadow 0.1s, border-color 0.15s;
}
.studio-item:hover {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
border-color: #d1d5db;
}
.studio-item:active {
cursor: grabbing;
}
.studio-item-main {
display: flex;
align-items: center;
gap: 8px;
}
.studio-item-icon {
font-size: 16px;
flex-shrink: 0;
line-height: 1;
}
.studio-item-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.studio-item-title {
font-size: 13px;
font-weight: 500;
color: #e8e8ec;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.studio-item-meta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.studio-item-tag {
font-size: 9px;
font-weight: 600;
color: #7c3aed;
background: #ede9fe;
border-radius: 3px;
padding: 0 4px;
line-height: 16px;
}
.studio-item-duration {
font-size: 11px;
color: #8a8a96;
font-variant-numeric: tabular-nums;
}
.studio-item-size {
font-size: 10px;
color: #5a5a66;
}
.studio-item-date {
font-size: 10px;
color: #5a5a66;
}
.studio-item-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.studio-item-versions {
font-size: 10px;
color: #059669;
white-space: nowrap;
}
.studio-edit-btn {
padding: 4px 10px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
background: #ede9fe;
color: #7c3aed;
text-decoration: none;
transition: background 0.1s;
white-space: nowrap;
}
.studio-edit-btn:hover {
background: #ddd6fe;
}
/* ================================================================= */
/* Responsive within bounded container */
/* ================================================================= */
/* Small panels: compact layout */
@container (max-width: 360px) {
.studio-header {
padding: 6px 8px;
}
.studio-items {
padding: 4px;
}
.studio-item {
padding: 6px 8px;
}
.studio-item-icon {
display: none;
}
.studio-item-title {
font-size: 12px;
}
.studio-item-meta {
gap: 4px;
}
.studio-item-versions {
display: none;
}
.studio-edit-btn {
padding: 3px 8px;
font-size: 10px;
}
.studio-empty {
padding: 16px;
font-size: 12px;
}
.studio-empty-icon {
font-size: 22px;
}
}
/* Medium panels: slightly tighter */
@container (max-width: 500px) {
.studio-item-size {
display: none;
}
.studio-item-date {
display: none;
}
}
/* Mobile viewport fallback */
@media (max-width: 480px) {
.studio-header {
padding: 6px 8px;
}
.studio-items {
padding: 4px;
}
.studio-item {
padding: 6px 8px;
}
.studio-item-icon {
display: none;
}
.studio-item-title {
font-size: 12px;
}
.studio-item-size {
display: none;
}
.studio-empty {
padding: 16px;
}
}
</style>