chore(frontend): move nuxt ui source into app directory

This commit is contained in:
Ruslan Bakiev
2026-02-23 10:03:24 +07:00
parent c41849745c
commit d4af315e2e
6 changed files with 20 additions and 20 deletions

5
frontend/app/app.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

View File

@@ -0,0 +1,16 @@
@import "tailwindcss";
@plugin "daisyui";
:root {
--color-accent: #1e6bff;
}
body {
min-height: 100vh;
background:
radial-gradient(circle at 100% 0%, rgba(30, 107, 255, 0.08), transparent 40%),
radial-gradient(circle at 0% 100%, rgba(30, 107, 255, 0.08), transparent 40%),
#f5f7fb;
color: #111827;
}

View File

@@ -0,0 +1,19 @@
import type { Meta, StoryObj } from "@storybook/vue3";
import ContactCollaborativeEditor from "./ContactCollaborativeEditor.client.vue";
const meta: Meta<typeof ContactCollaborativeEditor> = {
title: "Components/ContactCollaborativeEditor",
component: ContactCollaborativeEditor,
args: {
modelValue: "<p>Client summary draft...</p>",
room: "storybook-contact-editor-room",
placeholder: "Type here...",
plain: false,
},
};
export default meta;
type Story = StoryObj<typeof ContactCollaborativeEditor>;
export const Default: Story = {};

View File

@@ -0,0 +1,238 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { EditorContent, useEditor } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
import Collaboration from "@tiptap/extension-collaboration";
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
import Placeholder from "@tiptap/extension-placeholder";
import * as Y from "yjs";
import { WebrtcProvider } from "y-webrtc";
const props = defineProps<{
modelValue: string;
room: string;
placeholder?: string;
plain?: boolean;
}>();
const emit = defineEmits<{
(event: "update:modelValue", value: string): void;
}>();
const ydoc = new Y.Doc();
const provider = new WebrtcProvider(props.room, ydoc);
const isBootstrapped = ref(false);
const awarenessVersion = ref(0);
const userPalette = ["#2563eb", "#0ea5e9", "#14b8a6", "#16a34a", "#eab308", "#f97316", "#ef4444"];
const currentUser = {
name: `You ${Math.floor(Math.random() * 900 + 100)}`,
color: userPalette[Math.floor(Math.random() * userPalette.length)],
};
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function normalizeInitialContent(value: string) {
const input = value.trim();
if (!input) return "<p></p>";
if (input.includes("<") && input.includes(">")) return value;
const blocks = value
.replaceAll("\r\n", "\n")
.split(/\n\n+/)
.map((block) => `<p>${escapeHtml(block).replaceAll("\n", "<br />")}</p>`);
return blocks.join("");
}
const editor = useEditor({
extensions: [
StarterKit.configure({
history: false,
}),
Placeholder.configure({
placeholder: props.placeholder ?? "Type here...",
includeChildren: true,
}),
Collaboration.configure({
document: ydoc,
field: "contact",
}),
CollaborationCursor.configure({
provider,
user: currentUser,
}),
],
autofocus: true,
editorProps: {
attributes: {
class: "contact-editor-content",
spellcheck: "true",
},
},
onCreate: ({ editor: instance }) => {
if (instance.isEmpty) {
instance.commands.setContent(normalizeInitialContent(props.modelValue), false);
}
isBootstrapped.value = true;
},
onUpdate: ({ editor: instance }) => {
emit("update:modelValue", instance.getHTML());
},
});
watch(
() => props.modelValue,
(incoming) => {
const instance = editor.value;
if (!instance || !isBootstrapped.value) return;
const current = instance.getHTML();
if (incoming === current || !incoming.trim()) return;
if (instance.isEmpty) {
instance.commands.setContent(normalizeInitialContent(incoming), false);
}
},
);
const peerCount = computed(() => {
awarenessVersion.value;
const states = Array.from(provider.awareness.getStates().values());
return states.length;
});
const onAwarenessChange = () => {
awarenessVersion.value += 1;
};
provider.awareness.on("change", onAwarenessChange);
function runCommand(action: () => void) {
const instance = editor.value;
if (!instance) return;
action();
instance.commands.focus();
}
onBeforeUnmount(() => {
provider.awareness.off("change", onAwarenessChange);
editor.value?.destroy();
provider.destroy();
ydoc.destroy();
});
</script>
<template>
<div :class="props.plain ? 'space-y-2' : 'space-y-3'">
<div :class="props.plain ? 'flex flex-wrap items-center justify-between gap-2 bg-transparent p-0' : 'flex flex-wrap items-center justify-between gap-2 rounded-xl border border-base-300 bg-base-100 p-2'">
<div class="flex flex-wrap items-center gap-1">
<button
class="btn btn-xs"
:class="editor?.isActive('bold') ? 'btn-primary' : 'btn-ghost'"
@click="runCommand(() => editor?.chain().focus().toggleBold().run())"
>
B
</button>
<button
class="btn btn-xs"
:class="editor?.isActive('italic') ? 'btn-primary' : 'btn-ghost'"
@click="runCommand(() => editor?.chain().focus().toggleItalic().run())"
>
I
</button>
<button
class="btn btn-xs"
:class="editor?.isActive('bulletList') ? 'btn-primary' : 'btn-ghost'"
@click="runCommand(() => editor?.chain().focus().toggleBulletList().run())"
>
List
</button>
<button
class="btn btn-xs"
:class="editor?.isActive('heading', { level: 2 }) ? 'btn-primary' : 'btn-ghost'"
@click="runCommand(() => editor?.chain().focus().toggleHeading({ level: 2 }).run())"
>
H2
</button>
<button
class="btn btn-xs"
:class="editor?.isActive('blockquote') ? 'btn-primary' : 'btn-ghost'"
@click="runCommand(() => editor?.chain().focus().toggleBlockquote().run())"
>
Quote
</button>
</div>
<p class="px-1 text-xs text-base-content/60">Live: {{ peerCount }}</p>
</div>
<div :class="props.plain ? 'bg-transparent p-0' : 'rounded-xl border border-base-300 bg-base-100 p-2'">
<EditorContent :editor="editor" class="contact-editor min-h-[420px]" />
</div>
</div>
</template>
<style scoped>
.contact-editor :deep(.ProseMirror) {
min-height: 390px;
padding: 0.75rem;
outline: none;
line-height: 1.65;
color: rgba(17, 24, 39, 0.95);
}
.contact-editor :deep(.ProseMirror p) {
margin: 0.45rem 0;
}
.contact-editor :deep(.ProseMirror h1),
.contact-editor :deep(.ProseMirror h2),
.contact-editor :deep(.ProseMirror h3) {
margin: 0.75rem 0 0.45rem;
font-weight: 700;
line-height: 1.3;
}
.contact-editor :deep(.ProseMirror ul),
.contact-editor :deep(.ProseMirror ol) {
margin: 0.45rem 0;
padding-left: 1.25rem;
}
.contact-editor :deep(.ProseMirror blockquote) {
margin: 0.6rem 0;
border-left: 3px solid rgba(30, 107, 255, 0.5);
padding-left: 0.75rem;
color: rgba(55, 65, 81, 0.95);
}
.contact-editor :deep(.ProseMirror .collaboration-cursor__caret) {
margin-left: -1px;
margin-right: -1px;
border-left: 1px solid currentColor;
border-right: 1px solid currentColor;
pointer-events: none;
position: relative;
}
.contact-editor :deep(.ProseMirror .collaboration-cursor__label) {
position: absolute;
top: -1.35em;
left: -1px;
border-radius: 4px;
padding: 0.1rem 0.4rem;
color: #fff;
font-size: 10px;
font-weight: 600;
white-space: nowrap;
line-height: 1.2;
}
</style>

View File

@@ -0,0 +1,36 @@
export const CONTACT_DOCUMENT_SCOPE_PREFIX = "contact:";
export function buildContactDocumentScope(contactId: string, contactName: string) {
return `${CONTACT_DOCUMENT_SCOPE_PREFIX}${encodeURIComponent(contactId)}:${encodeURIComponent(contactName)}`;
}
export function parseContactDocumentScope(scope: string) {
const raw = String(scope ?? "").trim();
if (!raw.startsWith(CONTACT_DOCUMENT_SCOPE_PREFIX)) return null;
const payload = raw.slice(CONTACT_DOCUMENT_SCOPE_PREFIX.length);
const [idRaw, ...nameParts] = payload.split(":");
const contactId = decodeURIComponent(idRaw ?? "").trim();
const contactName = decodeURIComponent(nameParts.join(":") ?? "").trim();
if (!contactId) return null;
return {
contactId,
contactName,
};
}
export function formatDocumentScope(scope: string) {
const linked = parseContactDocumentScope(scope);
if (!linked) return scope;
return linked.contactName ? `Contact · ${linked.contactName}` : "Contact document";
}
export function isDocumentLinkedToContact(
scope: string,
contact: { id: string; name: string } | null | undefined,
) {
if (!contact) return false;
const linked = parseContactDocumentScope(scope);
if (!linked) return false;
if (linked.contactId) return linked.contactId === contact.id;
return Boolean(linked.contactName && linked.contactName === contact.name);
}

7163
frontend/app/pages/index.vue Normal file

File diff suppressed because it is too large Load Diff