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:
parent
204d46866f
commit
5babad9e59
4 changed files with 435 additions and 30 deletions
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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="ml-2 shrink-0 rounded bg-blue-50 px-3 py-1 text-xs text-blue-600 transition-colors hover:bg-blue-100"
|
||||
class="studio-edit-btn"
|
||||
title="Åpne i lydstudio"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Rediger
|
||||
</a>
|
||||
</li>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
3
tasks.md
3
tasks.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue