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:
parent
c46c9e364e
commit
f092afd2ba
10 changed files with 9 additions and 404 deletions
|
|
@ -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">←</a>
|
||||
<a href="/" class="context-back" title="Tilbake til arbeidsflaten">←</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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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">← Mottak</a>
|
||||
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">← Arbeidsflaten</a>
|
||||
<h1 class="text-lg font-semibold text-gray-900">
|
||||
{boardNode?.title || 'Kanban-brett'}
|
||||
</h1>
|
||||
|
|
|
|||
|
|
@ -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">← Mottak</a>
|
||||
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">← Arbeidsflaten</a>
|
||||
<h1 class="text-lg font-semibold text-gray-900">Kalender</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">← Mottak</a>
|
||||
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">← Arbeidsflaten</a>
|
||||
<h1 class="text-lg font-semibold text-gray-900">Ny samling</h1>
|
||||
</div>
|
||||
{#if connected}
|
||||
|
|
|
|||
|
|
@ -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">← Mottak</a>
|
||||
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">← Arbeidsflaten</a>
|
||||
<h1 class="text-lg font-semibold text-gray-900">Dagbok</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
|
|
|
|||
|
|
@ -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">← Mottak</a>
|
||||
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">← Arbeidsflaten</a>
|
||||
<h1 class="text-lg font-semibold text-gray-900">Kunnskapsgraf</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue