Gjør CalendarTrait til fullverdig BlockShell-panel med inline kalender (oppgave 20.7)
CalendarTrait var tidligere en stub med lenke til /calendar-ruten. Nå er hele kalender-UI-et innebygd direkte i panelet: - Månedsgrid med navigasjon (forrige/neste/i dag) - Hendelser vises som fargekodede chips i dagceller - Drag-and-drop mellom datoer for å flytte hendelser - Inline opprettelse av nye hendelser (tittel + valgfri tid) - setDragPayload for å dra hendelser til andre paneler - BlockReceiver: aksepterer content/communication-noder fra chat/kanban - Responsivt med @container queries for små paneler - Hendelsesliste under grid for oversikt over måneden Følger samme mønster som ChatTrait (20.5) og KanbanTrait (20.6). accessToken sendes nå til CalendarTrait fra collection-siden. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8a029bb280
commit
9035f09667
3 changed files with 880 additions and 41 deletions
|
|
@ -1,19 +1,22 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Node } from '$lib/spacetime';
|
import type { Node, Edge } from '$lib/spacetime';
|
||||||
import { edgeStore, nodeStore } from '$lib/spacetime';
|
import { edgeStore, nodeStore, nodeVisibility, connectionState } from '$lib/spacetime';
|
||||||
import { checkCalendarCompat, type DragPayload } from '$lib/transfer';
|
import { createNode, createEdge, updateEdge } from '$lib/api';
|
||||||
|
import { setDragPayload, checkCalendarCompat, type DragPayload } from '$lib/transfer';
|
||||||
import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types';
|
import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types';
|
||||||
import TraitPanel from './TraitPanel.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
collection: Node;
|
collection: Node;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
accessToken?: string;
|
||||||
/** Called when a drop is received on this panel */
|
/** Called when a drop is received on this panel */
|
||||||
onReceiveDrop?: (payload: DragPayload, intent: PlacementIntent) => void;
|
onReceiveDrop?: (payload: DragPayload, intent: PlacementIntent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { collection, config, userId, onReceiveDrop }: Props = $props();
|
let { collection, config, userId, accessToken, onReceiveDrop }: Props = $props();
|
||||||
|
|
||||||
|
const connected = $derived(connectionState.current === 'connected');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BlockReceiver implementation for Calendar.
|
* BlockReceiver implementation for Calendar.
|
||||||
|
|
@ -39,45 +42,882 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Scheduled events connected to this collection */
|
// =========================================================================
|
||||||
const events = $derived.by(() => {
|
// Calendar state
|
||||||
const items: { node: Node; when: string }[] = [];
|
// =========================================================================
|
||||||
for (const edge of edgeStore.byTarget(collection.id)) {
|
|
||||||
if (edge.edgeType !== 'scheduled') continue;
|
const today = new Date();
|
||||||
|
let viewYear = $state(today.getFullYear());
|
||||||
|
let viewMonth = $state(today.getMonth()); // 0-indexed
|
||||||
|
|
||||||
|
const monthNames = [
|
||||||
|
'Januar', 'Februar', 'Mars', 'April', 'Mai', 'Juni',
|
||||||
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Desember'
|
||||||
|
];
|
||||||
|
const dayNames = ['Ma', 'Ti', 'On', 'To', 'Fr', 'Lø', 'Sø'];
|
||||||
|
|
||||||
|
function prevMonth() {
|
||||||
|
if (viewMonth === 0) { viewMonth = 11; viewYear--; }
|
||||||
|
else { viewMonth--; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextMonth() {
|
||||||
|
if (viewMonth === 11) { viewMonth = 0; viewYear++; }
|
||||||
|
else { viewMonth++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToday() {
|
||||||
|
viewYear = today.getFullYear();
|
||||||
|
viewMonth = today.getMonth();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Calendar grid computation
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
interface CalendarDay {
|
||||||
|
date: Date;
|
||||||
|
dayOfMonth: number;
|
||||||
|
isCurrentMonth: boolean;
|
||||||
|
isToday: boolean;
|
||||||
|
dateKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateKey(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarDays = $derived.by((): CalendarDay[] => {
|
||||||
|
const firstOfMonth = new Date(viewYear, viewMonth, 1);
|
||||||
|
let startDow = firstOfMonth.getDay() - 1;
|
||||||
|
if (startDow < 0) startDow = 6;
|
||||||
|
|
||||||
|
const days: CalendarDay[] = [];
|
||||||
|
const startDate = new Date(viewYear, viewMonth, 1 - startDow);
|
||||||
|
const todayKey = formatDateKey(today);
|
||||||
|
|
||||||
|
for (let i = 0; i < 42; i++) {
|
||||||
|
const d = new Date(startDate);
|
||||||
|
d.setDate(startDate.getDate() + i);
|
||||||
|
const dateKey = formatDateKey(d);
|
||||||
|
days.push({
|
||||||
|
date: d,
|
||||||
|
dayOfMonth: d.getDate(),
|
||||||
|
isCurrentMonth: d.getMonth() === viewMonth && d.getFullYear() === viewYear,
|
||||||
|
isToday: dateKey === todayKey,
|
||||||
|
dateKey
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Scheduled events from SpacetimeDB
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
interface ScheduledEvent {
|
||||||
|
node: Node;
|
||||||
|
edge: Edge;
|
||||||
|
scheduledAt: Date;
|
||||||
|
dateKey: string;
|
||||||
|
timeStr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduledEvents = $derived.by((): ScheduledEvent[] => {
|
||||||
|
if (!connected || !userId) return [];
|
||||||
|
|
||||||
|
const events: ScheduledEvent[] = [];
|
||||||
|
|
||||||
|
for (const edge of edgeStore.byType('scheduled')) {
|
||||||
const node = nodeStore.get(edge.sourceId);
|
const node = nodeStore.get(edge.sourceId);
|
||||||
if (!node) continue;
|
if (!node || nodeVisibility(node, userId) === 'hidden') continue;
|
||||||
let when = '';
|
|
||||||
|
let at: string | undefined;
|
||||||
try {
|
try {
|
||||||
const meta = JSON.parse(edge.metadata ?? '{}');
|
const meta = JSON.parse(edge.metadata ?? '{}');
|
||||||
when = meta.scheduled_at ?? meta.date ?? '';
|
at = meta.at;
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
items.push({ node, when });
|
|
||||||
|
if (!at) continue;
|
||||||
|
|
||||||
|
const scheduledAt = new Date(at);
|
||||||
|
if (isNaN(scheduledAt.getTime())) continue;
|
||||||
|
|
||||||
|
const dateKey = formatDateKey(scheduledAt);
|
||||||
|
const hours = scheduledAt.getHours();
|
||||||
|
const minutes = scheduledAt.getMinutes();
|
||||||
|
const isAllDay = hours === 12 && minutes === 0;
|
||||||
|
const timeStr = isAllDay
|
||||||
|
? ''
|
||||||
|
: `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
events.push({ node, edge, scheduledAt, dateKey, timeStr });
|
||||||
}
|
}
|
||||||
items.sort((a, b) => a.when.localeCompare(b.when));
|
|
||||||
return items;
|
events.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
|
||||||
|
return events;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const eventsByDate = $derived.by((): Map<string, ScheduledEvent[]> => {
|
||||||
|
const map = new Map<string, ScheduledEvent[]>();
|
||||||
|
for (const ev of scheduledEvents) {
|
||||||
|
const list = map.get(ev.dateKey);
|
||||||
|
if (list) { list.push(ev); }
|
||||||
|
else { map.set(ev.dateKey, [ev]); }
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Internal drag-and-drop (move events between dates)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
let draggedEvent = $state<ScheduledEvent | null>(null);
|
||||||
|
let dragOverDate = $state<string | null>(null);
|
||||||
|
|
||||||
|
function handleDragStart(e: DragEvent, event: ScheduledEvent) {
|
||||||
|
draggedEvent = event;
|
||||||
|
if (!e.dataTransfer) return;
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
// Set cross-panel payload for dragging events to other panels
|
||||||
|
setDragPayload(e.dataTransfer, {
|
||||||
|
nodeId: event.node.id,
|
||||||
|
nodeKind: event.node.nodeKind,
|
||||||
|
sourcePanel: 'calendar',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e: DragEvent, dateKey: string) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
|
||||||
|
dragOverDate = dateKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave() {
|
||||||
|
dragOverDate = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDrop(e: DragEvent, dateKey: string) {
|
||||||
|
e.preventDefault();
|
||||||
|
dragOverDate = null;
|
||||||
|
|
||||||
|
if (!draggedEvent || !accessToken) return;
|
||||||
|
if (draggedEvent.dateKey === dateKey) {
|
||||||
|
draggedEvent = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = draggedEvent;
|
||||||
|
draggedEvent = null;
|
||||||
|
|
||||||
|
const [y, m, d] = dateKey.split('-').map(Number);
|
||||||
|
const newDate = new Date(y, m - 1, d,
|
||||||
|
event.scheduledAt.getHours(),
|
||||||
|
event.scheduledAt.getMinutes()
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateEdge(accessToken, {
|
||||||
|
edge_id: event.edge.id,
|
||||||
|
metadata: { at: newDate.toISOString() }
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Feil ved flytting av hendelse:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd() {
|
||||||
|
draggedEvent = null;
|
||||||
|
dragOverDate = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Create new scheduled event
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
let addingToDate = $state<string | null>(null);
|
||||||
|
let newEventTitle = $state('');
|
||||||
|
let newEventTime = $state('');
|
||||||
|
let isCreating = $state(false);
|
||||||
|
|
||||||
|
function startAddEvent(dateKey: string) {
|
||||||
|
addingToDate = dateKey;
|
||||||
|
newEventTitle = '';
|
||||||
|
newEventTime = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreateEvent() {
|
||||||
|
if (!accessToken || !userId || !addingToDate || !newEventTitle.trim() || isCreating) return;
|
||||||
|
|
||||||
|
isCreating = true;
|
||||||
|
try {
|
||||||
|
const [y, m, d] = addingToDate.split('-').map(Number);
|
||||||
|
let scheduledDate: Date;
|
||||||
|
if (newEventTime) {
|
||||||
|
const [h, min] = newEventTime.split(':').map(Number);
|
||||||
|
scheduledDate = new Date(y, m - 1, d, h, min);
|
||||||
|
} else {
|
||||||
|
scheduledDate = new Date(y, m - 1, d, 12, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { node_id } = await createNode(accessToken, {
|
||||||
|
node_kind: 'content',
|
||||||
|
title: newEventTitle.trim(),
|
||||||
|
visibility: 'hidden'
|
||||||
|
});
|
||||||
|
|
||||||
|
await createEdge(accessToken, {
|
||||||
|
source_id: userId,
|
||||||
|
target_id: node_id,
|
||||||
|
edge_type: 'owner'
|
||||||
|
});
|
||||||
|
|
||||||
|
await createEdge(accessToken, {
|
||||||
|
source_id: node_id,
|
||||||
|
target_id: node_id,
|
||||||
|
edge_type: 'scheduled',
|
||||||
|
metadata: { at: scheduledDate.toISOString() }
|
||||||
|
});
|
||||||
|
|
||||||
|
newEventTitle = '';
|
||||||
|
newEventTime = '';
|
||||||
|
addingToDate = null;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Feil ved oppretting av hendelse:', err);
|
||||||
|
} finally {
|
||||||
|
isCreating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEventKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleCreateEvent();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
addingToDate = null;
|
||||||
|
newEventTitle = '';
|
||||||
|
newEventTime = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Event colors based on node_kind
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
function eventColorClass(node: Node): string {
|
||||||
|
switch (node.nodeKind) {
|
||||||
|
case 'communication': return 'cal-event-comm';
|
||||||
|
case 'media': return 'cal-event-media';
|
||||||
|
case 'collection': return 'cal-event-coll';
|
||||||
|
default: return 'cal-event-default';
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<TraitPanel name="calendar" label="Kalender" icon="📅">
|
<!--
|
||||||
{#snippet children()}
|
CalendarTrait — fullverdig BlockShell-panel for kalender.
|
||||||
<a
|
Viser månedsgrid med hendelser, drag-and-drop mellom datoer,
|
||||||
href="/calendar"
|
inline opprettelse, og støtter mottak av noder fra andre paneler.
|
||||||
class="mb-3 inline-flex items-center gap-1.5 rounded bg-amber-100 px-3 py-1.5 text-xs font-medium text-amber-800 hover:bg-amber-200"
|
Forelder (collection page) wrapper dette i BlockShell.
|
||||||
>
|
-->
|
||||||
Åpne kalender →
|
|
||||||
</a>
|
<div class="cal-trait">
|
||||||
{#if events.length > 0}
|
{#if !connected}
|
||||||
<ul class="mt-2 space-y-1">
|
<div class="cal-empty">
|
||||||
{#each events.slice(0, 5) as ev (ev.node.id)}
|
<p>Venter på tilkobling…</p>
|
||||||
<li class="flex items-center gap-2 text-sm">
|
</div>
|
||||||
<span class="text-xs text-gray-400">{ev.when || '—'}</span>
|
{:else}
|
||||||
<span class="text-gray-900">{ev.node.title || 'Hendelse'}</span>
|
<!-- Month navigation -->
|
||||||
</li>
|
<div class="cal-nav">
|
||||||
|
<button onclick={prevMonth} class="cal-nav-btn" aria-label="Forrige måned">←</button>
|
||||||
|
<div class="cal-nav-center">
|
||||||
|
<span class="cal-nav-title">{monthNames[viewMonth]} {viewYear}</span>
|
||||||
|
<button onclick={goToday} class="cal-today-btn">I dag</button>
|
||||||
|
</div>
|
||||||
|
<button onclick={nextMonth} class="cal-nav-btn" aria-label="Neste måned">→</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Calendar grid -->
|
||||||
|
<div class="cal-grid-wrapper">
|
||||||
|
<!-- Day headers -->
|
||||||
|
<div class="cal-day-headers">
|
||||||
|
{#each dayNames as day}
|
||||||
|
<div class="cal-day-header">{day}</div>
|
||||||
{/each}
|
{/each}
|
||||||
{#if events.length > 5}
|
</div>
|
||||||
<li class="text-xs text-gray-400">+{events.length - 5} flere</li>
|
|
||||||
{/if}
|
<!-- Day cells -->
|
||||||
</ul>
|
<div class="cal-grid">
|
||||||
|
{#each calendarDays as day (day.dateKey)}
|
||||||
|
{@const dayEvents = eventsByDate.get(day.dateKey) ?? []}
|
||||||
|
{@const isDropTarget = dragOverDate === day.dateKey}
|
||||||
|
<div
|
||||||
|
class="cal-day
|
||||||
|
{day.isCurrentMonth ? '' : 'cal-day-other'}
|
||||||
|
{day.isToday ? 'cal-day-today' : ''}
|
||||||
|
{isDropTarget ? 'cal-day-drop' : ''}"
|
||||||
|
ondragover={(e: DragEvent) => handleDragOver(e, day.dateKey)}
|
||||||
|
ondragleave={handleDragLeave}
|
||||||
|
ondrop={(e: DragEvent) => handleDrop(e, day.dateKey)}
|
||||||
|
role="gridcell"
|
||||||
|
>
|
||||||
|
<!-- Date number + add button -->
|
||||||
|
<div class="cal-day-num-row">
|
||||||
|
<span class="cal-day-num {day.isToday ? 'cal-day-num-today' : ''}">
|
||||||
|
{day.dayOfMonth}
|
||||||
|
</span>
|
||||||
|
{#if day.isCurrentMonth && accessToken}
|
||||||
|
<button
|
||||||
|
onclick={() => startAddEvent(day.dateKey)}
|
||||||
|
class="cal-add-btn"
|
||||||
|
title="Legg til hendelse"
|
||||||
|
>+</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Events -->
|
||||||
|
{#each dayEvents as event (event.node.id)}
|
||||||
|
<div
|
||||||
|
class="cal-event {eventColorClass(event.node)} {draggedEvent?.node.id === event.node.id ? 'cal-event-dragging' : ''}"
|
||||||
|
draggable="true"
|
||||||
|
ondragstart={(e: DragEvent) => handleDragStart(e, event)}
|
||||||
|
ondragend={handleDragEnd}
|
||||||
|
title="{event.timeStr ? event.timeStr + ' ' : ''}{event.node.title || 'Uten tittel'}"
|
||||||
|
>
|
||||||
|
{#if event.timeStr}
|
||||||
|
<span class="cal-event-time">{event.timeStr}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="cal-event-title">{event.node.title || 'Uten tittel'}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<!-- Inline add form -->
|
||||||
|
{#if addingToDate === day.dateKey}
|
||||||
|
<div class="cal-add-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={newEventTitle}
|
||||||
|
onkeydown={handleEventKeydown}
|
||||||
|
placeholder="Tittel…"
|
||||||
|
class="cal-add-input"
|
||||||
|
disabled={isCreating}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
bind:value={newEventTime}
|
||||||
|
class="cal-add-input cal-add-time"
|
||||||
|
/>
|
||||||
|
<div class="cal-add-actions">
|
||||||
|
<button
|
||||||
|
onclick={handleCreateEvent}
|
||||||
|
disabled={isCreating || !newEventTitle.trim()}
|
||||||
|
class="cal-add-submit"
|
||||||
|
>
|
||||||
|
{isCreating ? '…' : 'Legg til'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => { addingToDate = null; newEventTitle = ''; newEventTime = ''; }}
|
||||||
|
class="cal-add-cancel"
|
||||||
|
>
|
||||||
|
Avbryt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Event list for current month -->
|
||||||
|
{@const monthEvents = scheduledEvents.filter(e => {
|
||||||
|
const d = e.scheduledAt;
|
||||||
|
return d.getMonth() === viewMonth && d.getFullYear() === viewYear;
|
||||||
|
})}
|
||||||
|
{#if monthEvents.length > 0}
|
||||||
|
<div class="cal-list">
|
||||||
|
<div class="cal-list-header">Hendelser denne måneden</div>
|
||||||
|
{#each monthEvents as event (event.node.id)}
|
||||||
|
<div
|
||||||
|
class="cal-list-item"
|
||||||
|
draggable="true"
|
||||||
|
ondragstart={(e: DragEvent) => handleDragStart(e, event)}
|
||||||
|
ondragend={handleDragEnd}
|
||||||
|
>
|
||||||
|
<span class="cal-list-date">
|
||||||
|
{event.scheduledAt.getDate()}. {monthNames[event.scheduledAt.getMonth()].slice(0, 3).toLowerCase()}
|
||||||
|
</span>
|
||||||
|
{#if event.timeStr}
|
||||||
|
<span class="cal-list-time">{event.timeStr}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="cal-list-title">{event.node.title || 'Uten tittel'}</span>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/if}
|
||||||
</TraitPanel>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ================================================================= */
|
||||||
|
/* Root — fills BlockShell content area */
|
||||||
|
/* ================================================================= */
|
||||||
|
.cal-trait {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================= */
|
||||||
|
/* Empty / loading state */
|
||||||
|
/* ================================================================= */
|
||||||
|
.cal-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: 1;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================= */
|
||||||
|
/* Month navigation */
|
||||||
|
/* ================================================================= */
|
||||||
|
.cal-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-nav-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-nav-btn:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-nav-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-nav-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-today-btn {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: none;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-today-btn:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================= */
|
||||||
|
/* Calendar grid */
|
||||||
|
/* ================================================================= */
|
||||||
|
.cal-grid-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day-headers {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
background: #f9fafb;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day-header {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #9ca3af;
|
||||||
|
padding: 4px 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================= */
|
||||||
|
/* Day cell */
|
||||||
|
/* ================================================================= */
|
||||||
|
.cal-day {
|
||||||
|
min-height: 56px;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
border-right: 1px solid #f3f4f6;
|
||||||
|
padding: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day-other {
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day-today {
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day-drop {
|
||||||
|
box-shadow: inset 0 0 0 2px #60a5fa;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day-num-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day-num {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day-other .cal-day-num {
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day-num-today {
|
||||||
|
background: #2563eb;
|
||||||
|
color: white;
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-add-btn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #d1d5db;
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-add-btn:hover {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================= */
|
||||||
|
/* Event chip */
|
||||||
|
/* ================================================================= */
|
||||||
|
.cal-event {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
margin-top: 1px;
|
||||||
|
padding: 1px 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
border: 1px solid;
|
||||||
|
cursor: grab;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.3;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-event:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-event-dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-event-default {
|
||||||
|
background: #fef3c7;
|
||||||
|
border-color: #fcd34d;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-event-comm {
|
||||||
|
background: #dbeafe;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-event-media {
|
||||||
|
background: #ede9fe;
|
||||||
|
border-color: #c4b5fd;
|
||||||
|
color: #5b21b6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-event-coll {
|
||||||
|
background: #d1fae5;
|
||||||
|
border-color: #6ee7b7;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-event-time {
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-event-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================= */
|
||||||
|
/* Inline add form */
|
||||||
|
/* ================================================================= */
|
||||||
|
.cal-add-form {
|
||||||
|
margin-top: 2px;
|
||||||
|
padding: 3px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-add-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
outline: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-add-input:focus {
|
||||||
|
border-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-add-time {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-add-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 3px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-add-submit {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
background: #2563eb;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-add-submit:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-add-submit:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-add-cancel {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-add-cancel:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================= */
|
||||||
|
/* Event list (below grid) */
|
||||||
|
/* ================================================================= */
|
||||||
|
.cal-list {
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
max-height: 140px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-list-header {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: #9ca3af;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: grab;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-list-item:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-list-item:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-list-date {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #9ca3af;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-list-time {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #6b7280;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-list-title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================================================= */
|
||||||
|
/* Responsive within bounded container */
|
||||||
|
/* ================================================================= */
|
||||||
|
|
||||||
|
/* Small panels: compact everything */
|
||||||
|
@container (max-width: 360px) {
|
||||||
|
.cal-nav {
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-nav-title {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-nav-btn {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day-num {
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day-header {
|
||||||
|
font-size: 8px;
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-event {
|
||||||
|
font-size: 8px;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-add-form {
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-list {
|
||||||
|
max-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-list-item {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Medium panels: slightly tighter */
|
||||||
|
@container (max-width: 500px) {
|
||||||
|
.cal-day {
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-event {
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile viewport fallback */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.cal-day {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day-num {
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-day-header {
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-event {
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cal-list {
|
||||||
|
max-height: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -344,7 +344,7 @@
|
||||||
{:else if trait === 'rss'}
|
{:else if trait === 'rss'}
|
||||||
<RssTrait collection={collectionNode} config={traits[trait]} />
|
<RssTrait collection={collectionNode} config={traits[trait]} />
|
||||||
{:else if trait === 'calendar'}
|
{:else if trait === 'calendar'}
|
||||||
<CalendarTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
|
<CalendarTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
||||||
{:else if trait === 'recording'}
|
{:else if trait === 'recording'}
|
||||||
<RecordingTrait collection={collectionNode} config={traits[trait]} {accessToken} />
|
<RecordingTrait collection={collectionNode} config={traits[trait]} {accessToken} />
|
||||||
{:else if trait === 'transcription'}
|
{:else if trait === 'transcription'}
|
||||||
|
|
@ -399,7 +399,7 @@
|
||||||
{:else if trait === 'rss'}
|
{:else if trait === 'rss'}
|
||||||
<RssTrait collection={collectionNode} config={traits[trait]} />
|
<RssTrait collection={collectionNode} config={traits[trait]} />
|
||||||
{:else if trait === 'calendar'}
|
{:else if trait === 'calendar'}
|
||||||
<CalendarTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
|
<CalendarTrait collection={collectionNode} config={traits[trait]} userId={nodeId} {accessToken} />
|
||||||
{:else if trait === 'recording'}
|
{:else if trait === 'recording'}
|
||||||
<RecordingTrait collection={collectionNode} config={traits[trait]} {accessToken} />
|
<RecordingTrait collection={collectionNode} config={traits[trait]} {accessToken} />
|
||||||
{:else if trait === 'transcription'}
|
{:else if trait === 'transcription'}
|
||||||
|
|
|
||||||
3
tasks.md
3
tasks.md
|
|
@ -229,8 +229,7 @@ Ref: `docs/features/universell_overfoering.md`, `docs/retninger/arbeidsflaten.md
|
||||||
- [x] 20.4 Transfer service: `innholdstransfer`-modus (ny node + source_material edge) og `lettvekts-triage` (eksisterende node + ny edge/placement). Bestem modus fra verktøy-par. Shift-modifier for override. Ref: `docs/features/universell_overfoering.md` § 1, 3.
|
- [x] 20.4 Transfer service: `innholdstransfer`-modus (ny node + source_material edge) og `lettvekts-triage` (eksisterende node + ny edge/placement). Bestem modus fra verktøy-par. Shift-modifier for override. Ref: `docs/features/universell_overfoering.md` § 1, 3.
|
||||||
- [x] 20.5 Panelrework — Chat: gjør ChatTrait til fullverdig BlockShell-panel med BlockReceiver, fullskjerm-toggle, og responsivt design innenfor begrenset container.
|
- [x] 20.5 Panelrework — Chat: gjør ChatTrait til fullverdig BlockShell-panel med BlockReceiver, fullskjerm-toggle, og responsivt design innenfor begrenset container.
|
||||||
- [x] 20.6 Panelrework — Kanban: gjør KanbanTrait til BlockShell-panel med drag-and-drop aksept fra andre paneler, fullskjerm, responsivt.
|
- [x] 20.6 Panelrework — Kanban: gjør KanbanTrait til BlockShell-panel med drag-and-drop aksept fra andre paneler, fullskjerm, responsivt.
|
||||||
- [~] 20.7 Panelrework — Kalender: gjør CalendarTrait til BlockShell-panel med drop-aksept for scheduling, fullskjerm, responsivt.
|
- [x] 20.7 Panelrework — Kalender: gjør CalendarTrait til BlockShell-panel med drop-aksept for scheduling, fullskjerm, responsivt.
|
||||||
> Påbegynt: 2026-03-18T08:35
|
|
||||||
- [ ] 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.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.
|
- [ ] 20.9 Panelrework — Studio: gjør StudioTrait til BlockShell-panel med drop-aksept for lydfiler, fullskjerm, responsivt.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue