Gjør StudioTrait til fullverdig BlockShell-panel med lydstudio-funksjonalitet (oppgave 20.9)

StudioTrait var en minimal liste med TraitPanel-wrapper. Nå er den et
fullverdig BlockShell-panel som følger mønsteret fra CalendarTrait (20.7)
og EditorTrait (20.8):

- Fjernet TraitPanel-wrapper, bruker egen flex-layout som fyller BlockShell
- Viser lydfiler med metadata: format-tag, varighet, størrelse, dato
- Drag-and-drop ut: lydfiler kan dras til andre paneler (editor, kalender)
- Drop-aksept: mottar lydfiler fra andre paneler via BlockReceiver
- Viser prosesserte versjoner (derived_from edges) per fil
- Responsivt med @container queries (360px, 500px) og @media fallback
- Lagt til `studio` trait i trait-katalogen i docs/primitiver/traits.md
- accessToken sendes nå fra collection page til StudioTrait

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-18 08:52:41 +00:00
parent 204d46866f
commit 5babad9e59
4 changed files with 435 additions and 30 deletions

View file

@ -104,6 +104,8 @@ Fravær av en trait betyr at funksjonaliteten er deaktivert. Ingen boolean
| `tts` | "Les opp"-knapp, lydversjon av artikler | Tekst-til-tale via jobbkø |
| `clips` | Klipp-editor, segment-markering | Segmentering, CAS-lagring av klipp |
| `playlist` | Ordnet avspillingsliste, drag-and-drop rekkefølge | Sekvensiell avspilling |
| `mixer` | Lydmixer: volumslidere, mute, VU-meter, sound pads, stemme-/EQ-effekter | Pad-konfig i metadata (se `docs/features/lydmixer.md`) |
| `studio` | Lydstudio-panel: lydfilliste med metadata, drag-ut, drop-aksept for lyd, lenke til fullverdig editor | Validering av lydformat, FFmpeg-pipeline via jobbkø (se `docs/features/lydstudio.md`) |
### Kommunikasjon
@ -148,6 +150,7 @@ Fravær av en trait betyr at funksjonaliteten er deaktivert. Ingen boolean
| `digest` | Periodisk oppsummering i UI | AI-sammendrag av aktivitet på intervall |
| `bridge` | "Også funnet i..."-forslag | pgvector-embedding, krysskontekst-søk |
| `moderation` | Moderasjonskø, flagging | AI-assistert innholdsvurdering |
| `ai_tool` | AI-verktøy-panel med prompt-velger, drag-and-drop tekstbehandling | Modellprofil-mapping, AI Gateway, ai_usage_log (se `docs/features/ai_verktoy.md`) |
### Tilgang & fellesskap
@ -177,16 +180,16 @@ Brukeren kan legge til eller fjerne traits etterpå.
| Pakke | Traits |
|---|---|
| **Nettmagasin** | editor(longform), publishing, rss, comments, analytics, custom_domain, newsletter |
| **Podcaststudio** | podcast, recording, transcription, editor(shownotes), rss, analytics, clips, knowledge_graph |
| **Podcaststudio** | podcast, recording, transcription, mixer, editor(shownotes), rss, analytics, clips, knowledge_graph |
| **Nyhetsbrev** | editor(longform), newsletter, analytics, versioning |
| **Wiki** | wiki, editor(longform), collaboration, versioning, knowledge_graph, glossary |
| **Diskusjonsklubb** | forum, chat, polls, membership, roles, directory |
| **Kursplattform** | editor(longform), playlist, qa, membership, paywall, templates |
| **Møteplass** | recording, chat, kanban, calendar, auto_summarize, guest_input |
| **Møteplass** | recording, mixer, chat, kanban, calendar, auto_summarize, guest_input |
| **Fotoblogg** | gallery, publishing, comments, custom_domain, rss |
| **Prosjektstyring** | kanban, calendar, chat, table, tags, roles |
| **Åpen forskning** | editor(longform), versioning, bibliography, publishing, comments, collaboration, api |
| **Community radio** | recording, podcast, chat, polls, membership, clips, playlist |
| **Community radio** | recording, mixer, podcast, chat, polls, membership, clips, playlist |
| **Bokmerke-vegg** | bookmarks, tags, publishing, rss, comments |
| **Redaksjon** | chat, kanban, calendar, editor(longform), knowledge_graph, guest_input |

View file

@ -1,19 +1,19 @@
<script lang="ts">
import type { Node } from '$lib/spacetime';
import { edgeStore, nodeStore, nodeVisibility } from '$lib/spacetime';
import { checkStudioCompat, type DragPayload } from '$lib/transfer';
import { setDragPayload, checkStudioCompat, type DragPayload } from '$lib/transfer';
import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types';
import TraitPanel from './TraitPanel.svelte';
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, onReceiveDrop }: Props = $props();
let { collection, config, userId, accessToken, onReceiveDrop }: Props = $props();
/**
* BlockReceiver implementation for Studio (Lydstudio).
@ -35,7 +35,10 @@
}
};
/** Media nodes (audio files) belonging to this collection */
// =========================================================================
// Audio nodes belonging to this collection
// =========================================================================
const audioNodes = $derived.by(() => {
const nodes: Node[] = [];
for (const edge of edgeStore.byTarget(collection.id)) {
@ -59,6 +62,10 @@
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)) {
@ -66,29 +73,425 @@
}
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?.microsSinceUnixEpoch) return '';
const ms = Number(node.createdAt.microsSinceUnixEpoch / 1000n);
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>
<TraitPanel title="Lydstudio" icon="studio">
<!--
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}
<p class="text-sm text-gray-400">Ingen lydfiler i denne samlingen enna.</p>
<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}
<ul class="space-y-2">
{#each audioNodes as node}
<li class="flex items-center justify-between rounded border border-gray-100 p-2">
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-gray-700">{node.title ?? 'Uten tittel'}</p>
{#if hasVersions(node.id)}
<span class="text-[10px] text-green-600">Har prosesserte versjoner</span>
{/if}
<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>
<a
href="/studio/{node.id}"
class="ml-2 shrink-0 rounded bg-blue-50 px-3 py-1 text-xs text-blue-600 transition-colors hover:bg-blue-100"
>
Rediger
</a>
</li>
</div>
{/each}
</ul>
</div>
{/if}
</TraitPanel>
</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: #9ca3af;
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: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.studio-header-count {
font-size: 11px;
color: #9ca3af;
background: #f3f4f6;
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: white;
border: 1px solid #e5e7eb;
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: #1f2937;
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: #6b7280;
font-variant-numeric: tabular-nums;
}
.studio-item-size {
font-size: 10px;
color: #9ca3af;
}
.studio-item-date {
font-size: 10px;
color: #9ca3af;
}
.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>

View file

@ -350,7 +350,7 @@
{:else if trait === 'transcription'}
<TranscriptionTrait collection={collectionNode} config={traits[trait]} />
{:else if trait === 'studio'}
<StudioTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
<StudioTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{:else if trait === 'mixer'}
<MixerTrait collection={collectionNode} config={traits[trait]} {accessToken} />
{/if}
@ -405,7 +405,7 @@
{:else if trait === 'transcription'}
<TranscriptionTrait collection={collectionNode} config={traits[trait]} />
{:else if trait === 'studio'}
<StudioTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
<StudioTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
{:else if trait === 'mixer'}
<MixerTrait collection={collectionNode} config={traits[trait]} {accessToken} />
{/if}

View file

@ -231,8 +231,7 @@ Ref: `docs/features/universell_overfoering.md`, `docs/retninger/arbeidsflaten.md
- [x] 20.6 Panelrework — Kanban: gjør KanbanTrait til BlockShell-panel med drag-and-drop aksept fra andre paneler, fullskjerm, responsivt.
- [x] 20.7 Panelrework — Kalender: gjør CalendarTrait til BlockShell-panel med drop-aksept for scheduling, fullskjerm, responsivt.
- [x] 20.8 Panelrework — Editor/Artikkelverktøy: gjør artikkelverktøy til BlockShell-panel med source_material mottak fra andre paneler. Ref: `docs/features/artikkelverktoy.md`.
- [~] 20.9 Panelrework — Studio: gjør StudioTrait til BlockShell-panel med drop-aksept for lydfiler, fullskjerm, responsivt.
> Påbegynt: 2026-03-18T08:48
- [x] 20.9 Panelrework — Studio: gjør StudioTrait til BlockShell-panel med drop-aksept for lydfiler, fullskjerm, responsivt.
## Fase 21: CLI-verktøy — Unix-filosofi