Fjern mottak-konseptet: alle referanser peker til arbeidsflaten

- «Mottak» → «Arbeidsflaten» i alle tilbake-lenker
- goto('/workspace') → goto('/') i ContextHeader
- Slettet NodeEditor.svelte og NewChatDialog.svelte (kun brukt av mottak)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-19 04:47:10 +00:00
parent c46c9e364e
commit f092afd2ba
10 changed files with 9 additions and 404 deletions

View file

@ -168,7 +168,7 @@
<div class="context-header-inner">
<!-- Left: Back + Context selector -->
<div class="context-header-left">
<a href="/" class="context-back" title="Tilbake til mottak">&larr;</a>
<a href="/" class="context-back" title="Tilbake til arbeidsflaten">&larr;</a>
<div class="context-selector">
<button
@ -198,7 +198,7 @@
{#if !searchQuery.trim()}
<button
class="context-selector-item"
onclick={() => { selectorOpen = false; goto('/workspace'); }}
onclick={() => { selectorOpen = false; goto('/'); }}
>
<span class="context-selector-item-title">Min arbeidsflate</span>
</button>

View file

@ -1,152 +0,0 @@
<script lang="ts">
import { nodeStore, edgeStore } from '$lib/realtime';
import type { Node } from '$lib/realtime';
interface Props {
currentUserId: string;
onselect: (personId: string) => void;
onclose: () => void;
}
let { currentUserId, onselect, onclose }: Props = $props();
let search = $state('');
/**
* Available people to chat with: all person nodes except current user.
* For 1:1 chat we show person nodes the user can see.
*/
const people = $derived.by(() => {
const persons: Node[] = [];
for (const node of nodeStore.byKind('person')) {
if (node.id !== currentUserId) {
persons.push(node);
}
}
// Sort alphabetically by title
persons.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
return persons;
});
/** Filter by search term */
const filtered = $derived.by(() => {
if (!search.trim()) return people;
const q = search.trim().toLowerCase();
return people.filter(p => (p.title || '').toLowerCase().includes(q));
});
/**
* Check if there's already a 1:1 communication with this person.
* Returns the communication node ID if found, undefined otherwise.
*/
function existingChatWith(personId: string): string | undefined {
// Find communication nodes where both current user and person are participants
const userComms = new Set<string>();
for (const edge of edgeStore.bySource(currentUserId)) {
if (edge.edgeType === 'owner' || edge.edgeType === 'member_of') {
const target = nodeStore.get(edge.targetId);
if (target?.nodeKind === 'communication') {
userComms.add(edge.targetId);
}
}
}
for (const edge of edgeStore.bySource(personId)) {
if (edge.edgeType === 'owner' || edge.edgeType === 'member_of') {
if (userComms.has(edge.targetId)) {
return edge.targetId;
}
}
}
return undefined;
}
function handleSelect(personId: string) {
onselect(personId);
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onclose();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onclose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
onclick={handleBackdropClick}
class="fixed inset-0 z-50 flex items-start justify-center bg-black/40 pt-20"
>
<div class="w-full max-w-md rounded-xl bg-white shadow-xl">
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3">
<h2 class="text-lg font-semibold text-gray-900">Ny samtale</h2>
<button
onclick={onclose}
class="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Lukk"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Search -->
<div class="border-b border-gray-100 px-4 py-2">
<input
bind:value={search}
type="text"
placeholder="Søk etter person…"
class="w-full rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm placeholder:text-gray-400 focus:border-blue-300 focus:bg-white focus:outline-none focus:ring-1 focus:ring-blue-300"
/>
</div>
<!-- People list -->
<div class="max-h-80 overflow-y-auto">
{#if filtered.length === 0}
<p class="px-4 py-6 text-center text-sm text-gray-400">
{#if people.length === 0}
Ingen andre brukere funnet
{:else}
Ingen treff for «{search}»
{/if}
</p>
{:else}
<ul>
{#each filtered as person (person.id)}
{@const existing = existingChatWith(person.id)}
<li>
<button
onclick={() => handleSelect(person.id)}
class="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-gray-50"
>
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-blue-100 text-sm font-medium text-blue-700">
{(person.title || '?')[0].toUpperCase()}
</div>
<div class="min-w-0 flex-1">
<p class="font-medium text-gray-900">{person.title || 'Ukjent'}</p>
{#if existing}
<p class="text-xs text-gray-400">Har eksisterende samtale</p>
{/if}
</div>
<svg class="h-4 w-4 shrink-0 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
</div>

View file

@ -1,243 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import Placeholder from '@tiptap/extension-placeholder';
import { uploadMedia, casUrl } from '$lib/api';
import VoiceRecorder from './VoiceRecorder.svelte';
interface Props {
onsubmit: (data: { title: string; content: string; html: string }) => Promise<void>;
disabled?: boolean;
accessToken?: string;
}
let { onsubmit, disabled = false, accessToken }: Props = $props();
let editorElement: HTMLDivElement | undefined = $state();
let editor: Editor | undefined = $state();
let title = $state('');
let submitting = $state(false);
let uploading = $state(0);
let error = $state('');
let voiceMemoInfo = $state('');
async function handleImageUpload(file: File): Promise<void> {
if (!accessToken) {
error = 'Ikke innlogget — kan ikke laste opp bilder';
return;
}
if (!file.type.startsWith('image/')) return;
uploading++;
try {
const result = await uploadMedia(accessToken, {
file,
title: file.name,
visibility: 'hidden'
});
const src = casUrl(result.cas_hash);
editor?.chain().focus().setImage({
src,
alt: file.name,
title: file.name
}).run();
// Add data-node-id to the just-inserted image
// TipTap Image extension stores src/alt/title as attributes.
// We extend the node attribute below to include data-node-id.
} catch (e) {
error = e instanceof Error ? e.message : 'Feil ved bildeopplasting';
} finally {
uploading--;
}
}
/** Handle files from drop or paste events */
function handleFiles(files: FileList | File[]) {
for (const file of files) {
if (file.type.startsWith('image/')) {
handleImageUpload(file);
}
}
}
// Extend the Image extension to include data-node-id attribute
const CasImage = Image.extend({
addAttributes() {
return {
...this.parent?.(),
'data-node-id': {
default: null,
parseHTML: (element: HTMLElement) => element.getAttribute('data-node-id'),
renderHTML: (attributes: Record<string, unknown>) => {
if (!attributes['data-node-id']) return {};
return { 'data-node-id': attributes['data-node-id'] };
}
}
};
}
});
onMount(() => {
editor = new Editor({
element: editorElement!,
extensions: [
StarterKit.configure({
heading: { levels: [2, 3] },
codeBlock: false,
horizontalRule: false
}),
Link.configure({
openOnClick: false,
HTMLAttributes: { class: 'text-blue-600 underline' }
}),
CasImage.configure({
inline: false,
allowBase64: false,
HTMLAttributes: {
class: 'max-w-full h-auto rounded'
}
}),
Placeholder.configure({
placeholder: 'Skriv noe…'
})
],
editorProps: {
attributes: {
class: 'prose prose-sm max-w-none focus:outline-none min-h-[80px] px-3 py-2'
},
handleDrop: (_view, event, _slice, moved) => {
if (moved || !event.dataTransfer?.files?.length) return false;
const imageFiles = Array.from(event.dataTransfer.files).filter(f =>
f.type.startsWith('image/')
);
if (imageFiles.length === 0) return false;
event.preventDefault();
handleFiles(imageFiles);
return true;
},
handlePaste: (_view, event) => {
const files = event.clipboardData?.files;
if (!files?.length) return false;
const imageFiles = Array.from(files).filter(f =>
f.type.startsWith('image/')
);
if (imageFiles.length === 0) return false;
event.preventDefault();
handleFiles(imageFiles);
return true;
}
},
onTransaction: () => {
// Force Svelte reactivity
editor = editor;
}
});
});
onDestroy(() => {
editor?.destroy();
});
const isEmpty = $derived(!title.trim() && (!editor || editor.isEmpty));
async function handleSubmit() {
if (isEmpty || submitting || disabled || uploading > 0) return;
submitting = true;
error = '';
try {
const content = editor?.getText() ?? '';
const html = editor?.getHTML() ?? '';
await onsubmit({ title: title.trim(), content, html });
// Clear editor on success
title = '';
editor?.commands.clearContent();
} catch (e) {
error = e instanceof Error ? e.message : 'Noe gikk galt';
} finally {
submitting = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="rounded-lg border border-gray-200 bg-white shadow-sm" onkeydown={handleKeydown}>
<input
type="text"
bind:value={title}
placeholder="Tittel (valgfritt)"
disabled={disabled || submitting}
class="w-full border-b border-gray-100 bg-transparent px-3 py-2 text-sm font-medium text-gray-900 placeholder:text-gray-400 focus:outline-none"
/>
<div bind:this={editorElement}></div>
{#if uploading > 0}
<p class="px-3 py-1 text-xs text-blue-600">Laster opp {uploading} bilde{uploading > 1 ? 'r' : ''}</p>
{/if}
{#if error}
<p class="px-3 py-1 text-xs text-red-600">{error}</p>
{/if}
{#if voiceMemoInfo}
<p class="px-3 py-1 text-xs text-green-600">{voiceMemoInfo}</p>
{/if}
<div class="flex items-center justify-between border-t border-gray-100 px-3 py-2">
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400">
{#if editor}
Markdown · Ctrl+B/I · Dra/lim bilder · Ctrl+Enter for å sende
{/if}
</span>
<VoiceRecorder
{accessToken}
disabled={disabled || submitting}
onerror={(msg) => { error = msg; }}
onrecorded={() => { voiceMemoInfo = 'Talenotat lastet opp — transkriberes i bakgrunnen'; setTimeout(() => { voiceMemoInfo = ''; }, 5000); }}
/>
</div>
<button
onclick={handleSubmit}
disabled={isEmpty || submitting || disabled || uploading > 0}
class="rounded bg-blue-600 px-3 py-1 text-xs font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-40"
>
{#if uploading > 0}
Laster opp…
{:else if submitting}
Sender…
{:else}
Opprett node
{/if}
</button>
</div>
</div>
<style>
:global(.tiptap p.is-editor-empty:first-child::before) {
color: #9ca3af;
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
:global(.tiptap img) {
max-width: 100%;
height: auto;
border-radius: 0.375rem;
margin: 0.5rem 0;
}
</style>

View file

@ -249,7 +249,7 @@
<header class="border-b border-gray-200 bg-white">
<div class="flex items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">&larr; Mottak</a>
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">&larr; Arbeidsflaten</a>
<h1 class="text-lg font-semibold text-gray-900">
{boardNode?.title || 'Kanban-brett'}
</h1>

View file

@ -309,7 +309,7 @@
<header class="border-b border-gray-200 bg-white">
<div class="flex items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">&larr; Mottak</a>
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">&larr; Arbeidsflaten</a>
<h1 class="text-lg font-semibold text-gray-900">Kalender</h1>
</div>
<div class="flex items-center gap-3">

View file

@ -268,7 +268,7 @@
<div class="rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-sm text-yellow-800">
<p class="font-medium">Samtale ikke funnet</p>
<p class="mt-1">Kommunikasjonsnoden med ID {communicationId} finnes ikke eller er ikke tilgjengelig.</p>
<a href="/" class="mt-2 inline-block text-blue-600 hover:underline">Tilbake til mottak</a>
<a href="/" class="mt-2 inline-block text-blue-600 hover:underline">Tilbake til arbeidsflaten</a>
</div>
{:else if messages.length === 0}
<p class="text-center text-sm text-gray-400">

View file

@ -296,7 +296,7 @@
<div class="workspace-message workspace-message-warn">
<p class="workspace-message-title">Samling ikke funnet</p>
<p>Samlingsnoden med ID {collectionId} finnes ikke eller er ikke tilgjengelig.</p>
<a href="/" class="workspace-link">Tilbake til mottak</a>
<a href="/" class="workspace-link">Tilbake til arbeidsflaten</a>
</div>
{:else if traitNames.length === 0}
<div class="workspace-message">

View file

@ -96,7 +96,7 @@
<header class="border-b border-gray-200 bg-white">
<div class="mx-auto flex max-w-4xl items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">&larr; Mottak</a>
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">&larr; Arbeidsflaten</a>
<h1 class="text-lg font-semibold text-gray-900">Ny samling</h1>
</div>
{#if connected}

View file

@ -225,7 +225,7 @@
<header class="border-b border-gray-200 bg-white">
<div class="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">&larr; Mottak</a>
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">&larr; Arbeidsflaten</a>
<h1 class="text-lg font-semibold text-gray-900">Dagbok</h1>
</div>
<div class="flex items-center gap-3">

View file

@ -404,7 +404,7 @@
<header class="border-b border-gray-200 bg-white shrink-0">
<div class="flex items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">&larr; Mottak</a>
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">&larr; Arbeidsflaten</a>
<h1 class="text-lg font-semibold text-gray-900">Kunnskapsgraf</h1>
</div>
<div class="flex items-center gap-2">