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">
|
||||
import type { Node } from '$lib/spacetime';
|
||||
import { edgeStore, nodeStore } from '$lib/spacetime';
|
||||
import { checkCalendarCompat, type DragPayload } from '$lib/transfer';
|
||||
import type { Node, Edge } from '$lib/spacetime';
|
||||
import { edgeStore, nodeStore, nodeVisibility, connectionState } from '$lib/spacetime';
|
||||
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 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();
|
||||
|
||||
const connected = $derived(connectionState.current === 'connected');
|
||||
|
||||
/**
|
||||
* BlockReceiver implementation for Calendar.
|
||||
|
|
@ -39,45 +42,882 @@
|
|||
}
|
||||
};
|
||||
|
||||
/** Scheduled events connected to this collection */
|
||||
const events = $derived.by(() => {
|
||||
const items: { node: Node; when: string }[] = [];
|
||||
for (const edge of edgeStore.byTarget(collection.id)) {
|
||||
if (edge.edgeType !== 'scheduled') continue;
|
||||
// =========================================================================
|
||||
// Calendar state
|
||||
// =========================================================================
|
||||
|
||||
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);
|
||||
if (!node) continue;
|
||||
let when = '';
|
||||
if (!node || nodeVisibility(node, userId) === 'hidden') continue;
|
||||
|
||||
let at: string | undefined;
|
||||
try {
|
||||
const meta = JSON.parse(edge.metadata ?? '{}');
|
||||
when = meta.scheduled_at ?? meta.date ?? '';
|
||||
at = meta.at;
|
||||
} 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>
|
||||
|
||||
<TraitPanel name="calendar" label="Kalender" icon="📅">
|
||||
{#snippet children()}
|
||||
<a
|
||||
href="/calendar"
|
||||
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"
|
||||
>
|
||||
Åpne kalender →
|
||||
</a>
|
||||
{#if events.length > 0}
|
||||
<ul class="mt-2 space-y-1">
|
||||
{#each events.slice(0, 5) as ev (ev.node.id)}
|
||||
<li class="flex items-center gap-2 text-sm">
|
||||
<span class="text-xs text-gray-400">{ev.when || '—'}</span>
|
||||
<span class="text-gray-900">{ev.node.title || 'Hendelse'}</span>
|
||||
</li>
|
||||
<!--
|
||||
CalendarTrait — fullverdig BlockShell-panel for kalender.
|
||||
Viser månedsgrid med hendelser, drag-and-drop mellom datoer,
|
||||
inline opprettelse, og støtter mottak av noder fra andre paneler.
|
||||
Forelder (collection page) wrapper dette i BlockShell.
|
||||
-->
|
||||
|
||||
<div class="cal-trait">
|
||||
{#if !connected}
|
||||
<div class="cal-empty">
|
||||
<p>Venter på tilkobling…</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Month navigation -->
|
||||
<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}
|
||||
{#if events.length > 5}
|
||||
<li class="text-xs text-gray-400">+{events.length - 5} flere</li>
|
||||
</div>
|
||||
|
||||
<!-- Day cells -->
|
||||
<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}
|
||||
</ul>
|
||||
</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}
|
||||
{/snippet}
|
||||
</TraitPanel>
|
||||
<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}
|
||||
</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'}
|
||||
<RssTrait collection={collectionNode} config={traits[trait]} />
|
||||
{: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'}
|
||||
<RecordingTrait collection={collectionNode} config={traits[trait]} {accessToken} />
|
||||
{:else if trait === 'transcription'}
|
||||
|
|
@ -399,7 +399,7 @@
|
|||
{:else if trait === 'rss'}
|
||||
<RssTrait collection={collectionNode} config={traits[trait]} />
|
||||
{: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'}
|
||||
<RecordingTrait collection={collectionNode} config={traits[trait]} {accessToken} />
|
||||
{: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.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.
|
||||
- [~] 20.7 Panelrework — Kalender: gjør CalendarTrait til BlockShell-panel med drop-aksept for scheduling, fullskjerm, responsivt.
|
||||
> Påbegynt: 2026-03-18T08:35
|
||||
- [x] 20.7 Panelrework — Kalender: gjør CalendarTrait til BlockShell-panel med drop-aksept for scheduling, fullskjerm, responsivt.
|
||||
- [ ] 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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue