Initial Commit
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { EditorState, Compartment } from '@codemirror/state';
|
||||
import { EditorView, lineNumbers, keymap } from '@codemirror/view';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { typst, TypstParser, typstHighlight } from 'codemirror-lang-typst';
|
||||
import { Language } from '@codemirror/language';
|
||||
import { yCollab } from 'y-codemirror.next';
|
||||
import { text, provider } from '../ts/yjs-setup';
|
||||
import { getThemeExtension } from '../ts/themes';
|
||||
import { themeStore, darkModeStore, editorViewStore } from '../ts/store';
|
||||
|
||||
let editorContainer: HTMLElement;
|
||||
let view: EditorView;
|
||||
let themeCompartment = new Compartment();
|
||||
let unsubscribeTheme: () => void;
|
||||
let unsubscribeDark: () => void;
|
||||
|
||||
let currentTheme = 'Catppuccin';
|
||||
let isDark = true;
|
||||
let state: EditorState;
|
||||
|
||||
onMount(() => {
|
||||
if (!text || !provider) return;
|
||||
|
||||
themeStore.subscribe(t => { currentTheme = t; })();
|
||||
darkModeStore.subscribe(d => { isDark = d; })();
|
||||
|
||||
const t = typst();
|
||||
const myParser = new (TypstParser as any)(typstHighlight);
|
||||
const myLang = new Language(
|
||||
t.language.data,
|
||||
myParser,
|
||||
[myParser.updateListener()],
|
||||
'typst'
|
||||
);
|
||||
|
||||
state = EditorState.create({
|
||||
doc: text.toString(),
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
history(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap] as any),
|
||||
myLang,
|
||||
yCollab(text, provider.awareness),
|
||||
themeCompartment.of(getThemeExtension(currentTheme as any, isDark)),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.theme({
|
||||
'&': { height: '100%', fontSize: '14px' },
|
||||
'.cm-scroller': { overflow: 'auto' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
view = new EditorView({
|
||||
state,
|
||||
parent: editorContainer,
|
||||
});
|
||||
|
||||
editorViewStore.set(view);
|
||||
|
||||
unsubscribeTheme = themeStore.subscribe((themeName) => {
|
||||
if (view) {
|
||||
view.dispatch({
|
||||
effects: themeCompartment.reconfigure(getThemeExtension(themeName as any, isDark))
|
||||
});
|
||||
currentTheme = themeName;
|
||||
}
|
||||
});
|
||||
|
||||
unsubscribeDark = darkModeStore.subscribe((dark) => {
|
||||
if (view) {
|
||||
view.dispatch({
|
||||
effects: themeCompartment.reconfigure(getThemeExtension(currentTheme as any, dark))
|
||||
});
|
||||
isDark = dark;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (unsubscribeTheme) unsubscribeTheme();
|
||||
if (unsubscribeDark) unsubscribeDark();
|
||||
if (view) {
|
||||
view.destroy();
|
||||
}
|
||||
editorViewStore.set(null);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="h-full w-full overflow-hidden focus-within:ring-2 focus-within:ring-inset focus-within:ring-blue-500/20" bind:this={editorContainer}></div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { Diagnostic } from '../ts/typst-api';
|
||||
|
||||
let { errors = [] } = $props<{ errors: Diagnostic[] }>();
|
||||
</script>
|
||||
|
||||
{#if errors && errors.length > 0}
|
||||
<div class="absolute bottom-4 left-4 right-4 z-50 rounded-lg shadow-2xl bg-red-900/90 text-white p-4 max-h-48 overflow-y-auto backdrop-blur-sm border border-red-500/50">
|
||||
<h3 class="font-bold flex items-center gap-2 mb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1.2em" height="1.2em" viewBox="0 0 24 24"><path fill="currentColor" d="M12 17q.425 0 .713-.288T13 16t-.288-.712T12 15t-.712.288T11 16t.288.713T12 17m-1-4h2V7h-2zm1 9q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22"/></svg>
|
||||
Compilation Errors
|
||||
</h3>
|
||||
<ul class="text-sm font-mono flex flex-col gap-1">
|
||||
{#each errors as error}
|
||||
<li class="flex items-start gap-2">
|
||||
<span class="text-red-300">[{error.severity}]</span>
|
||||
<span>{error.message}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let props = $props<{
|
||||
onClose: () => void;
|
||||
onApply: (settings: Record<string, string>, docSettings: Record<string, string>) => void;
|
||||
currentSettings?: Record<string, string>;
|
||||
}>();
|
||||
|
||||
let settings = $derived(props.currentSettings || {});
|
||||
|
||||
|
||||
let paper = $state("");
|
||||
let margin = $state("");
|
||||
let width = $state("");
|
||||
let height = $state("");
|
||||
let flipped = $state(false);
|
||||
let columns = $state(1);
|
||||
let fill = $state("");
|
||||
let numbering = $state("");
|
||||
let header = $state("");
|
||||
let footer = $state("");
|
||||
|
||||
|
||||
let docTitle = $state("");
|
||||
let author = $state("");
|
||||
|
||||
$effect(() => {
|
||||
paper = settings.paper || 'a4';
|
||||
margin = settings.margin || 'auto';
|
||||
width = settings.width || 'auto';
|
||||
height = settings.height || 'auto';
|
||||
flipped = settings.flipped === 'true';
|
||||
columns = parseInt(settings.columns || '1') || 1;
|
||||
fill = settings.fill || 'auto';
|
||||
numbering = settings.numbering || 'none';
|
||||
header = settings.header || 'auto';
|
||||
footer = settings.footer || 'auto';
|
||||
docTitle = settings.docTitle || '';
|
||||
author = settings.author || '';
|
||||
});
|
||||
|
||||
function apply() {
|
||||
const newPageSettings: Record<string, string> = {};
|
||||
if (paper !== 'a4') newPageSettings.paper = `"${paper}"`;
|
||||
if (margin !== 'auto') newPageSettings.margin = margin;
|
||||
if (width !== 'auto') newPageSettings.width = width;
|
||||
if (height !== 'auto') newPageSettings.height = height;
|
||||
if (flipped) newPageSettings.flipped = 'true';
|
||||
if (columns !== 1) newPageSettings.columns = columns.toString();
|
||||
if (fill !== 'auto') newPageSettings.fill = fill;
|
||||
if (numbering !== 'none') newPageSettings.numbering = `"${numbering}"`;
|
||||
if (header !== 'auto') newPageSettings.header = header;
|
||||
if (footer !== 'auto') newPageSettings.footer = footer;
|
||||
|
||||
const newDocSettings: Record<string, string> = {};
|
||||
if (docTitle) newDocSettings.title = `"${docTitle}"`;
|
||||
if (author) newDocSettings.author = `"${author}"`;
|
||||
|
||||
props.onApply(newPageSettings, newDocSettings);
|
||||
props.onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200" role="presentation" onclick={() => props.onClose()} onkeydown={(e) => { if (e.key === "Enter") { props.onClose(); } }}>
|
||||
<div class="bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border border-gray-200 dark:border-zinc-800 w-full max-w-2xl overflow-hidden flex flex-col max-h-[85vh]" role="dialog" tabindex="-1" aria-modal="true" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.key === 'Escape' && props.onClose()}>
|
||||
<div class="flex justify-between items-center p-5 border-b border-gray-100 dark:border-zinc-800">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Icon icon="mdi:file-document-edit-outline" class="text-blue-500 text-xl" />
|
||||
Document & Page Settings
|
||||
</h2>
|
||||
<button onclick={() => props.onClose()} class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-full p-1 transition-colors">
|
||||
<Icon icon="mdi:close" class="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-8 overflow-y-auto flex-1">
|
||||
|
||||
<section>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider mb-4 border-b border-gray-100 dark:border-zinc-800 pb-2">Document Metadata</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label for="docTitle" class="text-sm font-medium text-gray-700 dark:text-gray-300">PDF Title</label>
|
||||
<input id="docTitle" type="text" bind:value={docTitle} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="My Report" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="author" class="text-sm font-medium text-gray-700 dark:text-gray-300">Author</label>
|
||||
<input id="author" type="text" bind:value={author} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="Jane Doe" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider mb-4 border-b border-gray-100 dark:border-zinc-800 pb-2">Page Layout</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label for="paper" class="text-sm font-medium text-gray-700 dark:text-gray-300">Paper Size</label>
|
||||
<select id="paper" bind:value={paper} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2">
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="a4">A4</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="us-letter">US Letter</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="a5">A5</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="presentation-16-9">16:9 Presentation</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="presentation-4-3">4:3 Presentation</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="margin" class="text-sm font-medium text-gray-700 dark:text-gray-300">Margin</label>
|
||||
<input id="margin" type="text" bind:value={margin} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="auto or 1in" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="width" class="text-sm font-medium text-gray-700 dark:text-gray-300">Width</label>
|
||||
<input id="width" type="text" bind:value={width} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="auto" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="height" class="text-sm font-medium text-gray-700 dark:text-gray-300">Height</label>
|
||||
<input id="height" type="text" bind:value={height} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="auto" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="columns" class="text-sm font-medium text-gray-700 dark:text-gray-300">Columns</label>
|
||||
<input id="columns" type="number" min="1" max="10" bind:value={columns} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="fill" class="text-sm font-medium text-gray-700 dark:text-gray-300">Background Fill</label>
|
||||
<input id="fill" type="text" bind:value={fill} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="auto or rgb(200, 200, 200)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-4">
|
||||
<input id="flipped" type="checkbox" bind:checked={flipped} class="rounded text-blue-600 focus:ring-blue-500 bg-gray-50 dark:bg-zinc-900 border-gray-300 dark:border-zinc-700" />
|
||||
<label for="flipped" class="text-sm font-medium text-gray-700 dark:text-gray-300">Landscape Orientation (Flipped)</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider mb-4 border-b border-gray-100 dark:border-zinc-800 pb-2">Headers & Footers</h3>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label for="numbering" class="text-sm font-medium text-gray-700 dark:text-gray-300">Page Numbering</label>
|
||||
<select id="numbering" bind:value={numbering} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2">
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="none">None</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="1">1, 2, 3</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="1/1">1/3, 2/3, 3/3</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="a">a, b, c</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="i">i, ii, iii</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="I">I, II, III</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label for="header" class="text-sm font-medium text-gray-700 dark:text-gray-300">Header Content</label>
|
||||
<input id="header" type="text" bind:value={header} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="auto or [Text]" />
|
||||
</div>
|
||||
<div class="space-y-2 sm:col-span-2">
|
||||
<label for="footer" class="text-sm font-medium text-gray-700 dark:text-gray-300">Footer Content</label>
|
||||
<input id="footer" type="text" bind:value={footer} class="w-full bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-white text-sm rounded-lg px-3 py-2" placeholder="auto or [Text]" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="p-5 border-t border-gray-100 dark:border-zinc-800 flex justify-end gap-3 bg-gray-50/50 dark:bg-zinc-900/50">
|
||||
<button onclick={() => props.onClose()} class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button onclick={apply} class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm">
|
||||
Apply Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import type { Diagnostic } from '../ts/typst-api';
|
||||
import { documentZoomStore } from '../ts/store';
|
||||
|
||||
let { svgs = [] } = $props<{ svgs?: string[] }>();
|
||||
</script>
|
||||
|
||||
<div class="relative h-full w-full overflow-auto bg-transparent py-8 px-4 flex flex-col items-center gap-8">
|
||||
<div class="flex flex-col items-center gap-8 transition-transform duration-200" style="transform: scale({$documentZoomStore / 100}); transform-origin: top center;">
|
||||
{#if svgs.length > 0}
|
||||
{#each svgs as svg, i}
|
||||
<div class="preview-container shadow-xl bg-white max-w-full lg:max-w-[95%] w-auto inline-block flex-shrink-0 transition-transform duration-200">
|
||||
{@html svg}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="text-gray-400 flex flex-col items-center justify-center h-full">
|
||||
<p>Document is empty or compiling...</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
:global(.preview-container svg) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
let { onClose, docId = undefined } = $props<{ onClose: () => void, docId?: string }>();
|
||||
|
||||
let link = $state('');
|
||||
let copied = $state(false);
|
||||
let role = $state('editor');
|
||||
|
||||
onMount(() => {
|
||||
|
||||
const baseUrl = window.location.origin;
|
||||
const docUrl = docId ? `${baseUrl}/doc/${docId}` : window.location.href;
|
||||
|
||||
|
||||
link = `${docUrl}?role=${role}`;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const baseUrl = window.location.origin;
|
||||
|
||||
let docUrl = docId ? `${baseUrl}/doc/${docId}` : window.location.href.split('?')[0];
|
||||
link = `${docUrl}?role=${role}`;
|
||||
});
|
||||
|
||||
function copyLink() {
|
||||
navigator.clipboard.writeText(link);
|
||||
copied = true;
|
||||
setTimeout(() => copied = false, 2000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200" role="presentation" onclick={onClose}>
|
||||
<div tabindex="-1" class="bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border border-gray-200 dark:border-zinc-800 w-full max-w-md overflow-hidden" role="dialog" aria-modal="true" aria-labelledby="share-dialog-title" onclick={(e) => e.stopPropagation()} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
|
||||
<div class="flex justify-between items-center p-4 border-b border-gray-100 dark:border-zinc-800">
|
||||
<h2 id="share-dialog-title" class="text-lg font-semibold text-gray-900 dark:text-white">Share Document</h2>
|
||||
<button onclick={onClose} aria-label="Close" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-full p-1 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label for="share-link-input" class="text-sm font-medium text-gray-700 dark:text-gray-300 block">Share Link</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
id="share-link-input"
|
||||
type="text"
|
||||
readonly
|
||||
value={link}
|
||||
class="flex-1 bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-600 dark:text-gray-400 text-sm rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
onclick={copyLink}
|
||||
aria-label="Copy link"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors shadow-sm min-w-[80px]"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 pt-2">
|
||||
<label for="general-access-select" class="text-sm font-medium text-gray-700 dark:text-gray-300 block">General Access</label>
|
||||
<div class="flex items-center gap-3 p-3 bg-gray-50 dark:bg-zinc-950/50 rounded-lg border border-gray-200 dark:border-zinc-800">
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 p-2 rounded-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-gray-600 dark:text-gray-400"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white">Anyone with the link</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Can view and collaborate</p>
|
||||
</div>
|
||||
<select id="general-access-select" bind:value={role} class="bg-transparent text-sm font-medium text-gray-700 dark:text-gray-300 focus:outline-none cursor-pointer">
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="viewer">Viewer</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="editor">Editor</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-gray-50 dark:bg-zinc-950/50 border-t border-gray-100 dark:border-zinc-800 flex justify-end">
|
||||
<button onclick={onClose} aria-label="Done" class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-md transition-colors">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { themeStore, darkModeStore } from '../ts/store';
|
||||
import { themes } from '../ts/themes';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let { class: className = '' } = $props();
|
||||
|
||||
const themeOptions = Object.keys(themes).flatMap(themeName => [
|
||||
{ name: `${themeName} Light`, theme: themeName, isDark: false },
|
||||
{ name: `${themeName} Dark`, theme: themeName, isDark: true }
|
||||
]);
|
||||
|
||||
function handleChange(e: Event) {
|
||||
const val = (e.target as HTMLSelectElement).value;
|
||||
const opt = themeOptions.find(o => o.name === val);
|
||||
if (opt) {
|
||||
$themeStore = opt.theme;
|
||||
$darkModeStore = opt.isDark;
|
||||
}
|
||||
}
|
||||
|
||||
let selectedValue = $derived(`${$themeStore} ${$darkModeStore ? 'Dark' : 'Light'}`);
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-2 {className}">
|
||||
<Icon icon={themes[$themeStore]?.icon || 'mdi:palette'} class="text-xl text-gray-500 dark:text-gray-400" />
|
||||
<select
|
||||
value={selectedValue}
|
||||
onchange={handleChange}
|
||||
class="bg-white/50 dark:bg-black/20 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-white/20 text-sm font-medium rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 block py-1.5 pl-3 pr-8 appearance-none cursor-pointer hover:border-gray-400 dark:hover:border-white/30 transition-colors outline-none"
|
||||
>
|
||||
{#each themeOptions as opt}
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value={opt.name}>{opt.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
@@ -0,0 +1,625 @@
|
||||
<script lang="ts">
|
||||
import { exportTypst } from '../ts/typst-api';
|
||||
import { text } from '../ts/yjs-setup';
|
||||
import { connectionStatus, connectedUsers, themeStore, darkModeStore, editorViewStore, documentZoomStore } from '../ts/store';
|
||||
import { themes } from '../ts/themes';
|
||||
import { goto } from '$app/navigation';
|
||||
import ShareModal from './ShareModal.svelte';
|
||||
import PageSettingsModal from './PageSettingsModal.svelte';
|
||||
import ThemePicker from './ThemePicker.svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let isShareModalOpen = $state(false);
|
||||
let isPageSettingsOpen = $state(false);
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
let { title = 'Untitled Document', docId = undefined } = $props<{ title?: string, docId?: string }>();
|
||||
|
||||
function handleExport(format: 'pdf' | 'png' | 'svg' | 'typ') {
|
||||
if (!text) return;
|
||||
const content = text.toString();
|
||||
const safeTitle = title.replace(/[^a-z0-9_-]/gi, '_');
|
||||
|
||||
if (format === 'typ') {
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${safeTitle}.typ`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
|
||||
exportTypst(content, format, safeTitle, docId).catch((e) => {
|
||||
console.error(`Export to ${format} failed:`, e);
|
||||
alert(`Failed to export as ${format.toUpperCase()}`);
|
||||
});
|
||||
}
|
||||
|
||||
function insertTypstConfig(setting: string, value: string) {
|
||||
if (!text) return;
|
||||
const content = text.toString();
|
||||
|
||||
const regex = new RegExp(`^#set\\s+${setting}\\s*\\(([^)]*)\\)`, 'm');
|
||||
const match = content.match(regex);
|
||||
|
||||
const [propKey, ...propValParts] = value.split(':');
|
||||
const propKeyTrimmed = propKey.trim();
|
||||
const propValTrimmed = propValParts.join(':').trim();
|
||||
|
||||
if (match) {
|
||||
const index = match.index!;
|
||||
const oldArgs = match[1];
|
||||
|
||||
|
||||
const propRegex = new RegExp(`${propKeyTrimmed}\\s*:\\s*(?:"[^"]*"|[^,]+)`);
|
||||
let newArgs;
|
||||
if (propRegex.test(oldArgs)) {
|
||||
newArgs = oldArgs.replace(propRegex, `${propKeyTrimmed}: ${propValTrimmed}`);
|
||||
} else {
|
||||
newArgs = oldArgs.trim() ? `${oldArgs}, ${propKeyTrimmed}: ${propValTrimmed}` : `${propKeyTrimmed}: ${propValTrimmed}`;
|
||||
}
|
||||
|
||||
const newRule = `#set ${setting}(${newArgs})`;
|
||||
const lengthToReplace = match[0].length;
|
||||
text.delete(index, lengthToReplace);
|
||||
text.insert(index, newRule);
|
||||
} else {
|
||||
text.insert(0, `#set ${setting}(${value})\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function applyFormat(prefix: string, suffix: string, defaultText: string = '') {
|
||||
const view = $editorViewStore;
|
||||
if (!view) return;
|
||||
|
||||
const selection = view.state.selection.main;
|
||||
const selectedText = view.state.doc.sliceString(selection.from, selection.to);
|
||||
const replacement = prefix + (selectedText || defaultText) + suffix;
|
||||
|
||||
view.dispatch({
|
||||
changes: { from: selection.from, to: selection.to, insert: replacement },
|
||||
selection: { anchor: selection.from + prefix.length, head: selection.from + prefix.length + (selectedText || defaultText).length }
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function handleImageUpload(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
if (!target.files || target.files.length === 0) return;
|
||||
const file = target.files[0];
|
||||
if (!docId) return alert('Please save the document before uploading images.');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
fetch(`/api/docs/${docId}/files`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then(res => res.json()).then(data => {
|
||||
if (data.filename) {
|
||||
const view = $editorViewStore;
|
||||
if (view) {
|
||||
const selection = view.state.selection.main;
|
||||
const isFont = data.filename.toLowerCase().endsWith('.ttf') || data.filename.toLowerCase().endsWith('.otf');
|
||||
const replacement = isFont ? `#set text(font: ("New Computer Modern", "${data.filename.replace(/\.[^/.]+$/, "")}"))\n` : `#image("${data.filename}")\n`;
|
||||
view.dispatch({
|
||||
changes: { from: selection.from, to: selection.to, insert: replacement },
|
||||
selection: { anchor: selection.from + replacement.length }
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
alert('Failed to upload image');
|
||||
});
|
||||
|
||||
target.value = '';
|
||||
}
|
||||
|
||||
function handlePageSettings(settings: Record<string, string>, docSettings: Record<string, string>) {
|
||||
if (!text) return;
|
||||
const content = text.toString();
|
||||
|
||||
|
||||
if (Object.keys(settings).length > 0) {
|
||||
let args = Object.entries(settings).map(([k, v]) => `${k}: ${v}`).join(', ');
|
||||
const regex = new RegExp(`^#set\\s+page\\s*\\(([^)]*)\\)`, 'm');
|
||||
const match = content.match(regex);
|
||||
|
||||
if (match) {
|
||||
const index = match.index!;
|
||||
const lengthToReplace = match[0].length;
|
||||
text.delete(index, lengthToReplace);
|
||||
text.insert(index, `#set page(${args})`);
|
||||
} else {
|
||||
text.insert(0, `#set page(${args})\n`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (Object.keys(docSettings).length > 0) {
|
||||
let docArgs = Object.entries(docSettings).map(([k, v]) => `${k}: ${v}`).join(', ');
|
||||
const docRegex = new RegExp(`^#set\\s+document\\s*\\(([^)]*)\\)`, 'm');
|
||||
const docMatch = text.toString().match(docRegex);
|
||||
|
||||
if (docMatch) {
|
||||
const index = docMatch.index!;
|
||||
const lengthToReplace = docMatch[0].length;
|
||||
text.delete(index, lengthToReplace);
|
||||
text.insert(index, `#set document(${docArgs})`);
|
||||
} else {
|
||||
text.insert(0, `#set document(${docArgs})\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const themeNames = Object.keys(themes);
|
||||
|
||||
function parseSettings() {
|
||||
if (!text) return {};
|
||||
const content = text.toString();
|
||||
const settings: Record<string, string> = {};
|
||||
|
||||
const pageMatch = content.match(/^#set\s+page\s*\(([^)]*)\)/m);
|
||||
if (pageMatch) {
|
||||
const args = pageMatch[1].split(',').map(s => s.trim());
|
||||
for (const arg of args) {
|
||||
const [k, ...vParts] = arg.split(':').map(s => s.trim());
|
||||
if (k && vParts.length) {
|
||||
let v = vParts.join(':').trim();
|
||||
|
||||
if (v.startsWith('"') && v.endsWith('"')) v = v.substring(1, v.length - 1);
|
||||
|
||||
if (v === 'true') v = 'true';
|
||||
if (v === 'false') v = 'false';
|
||||
settings[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const docMatch = content.match(/^#set\s+document\s*\(([^)]*)\)/m);
|
||||
if (docMatch) {
|
||||
const args = docMatch[1].split(',').map(s => s.trim());
|
||||
for (const arg of args) {
|
||||
const [k, ...vParts] = arg.split(':').map(s => s.trim());
|
||||
if (k && vParts.length) {
|
||||
let v = vParts.join(':').trim();
|
||||
if (v.startsWith('"') && v.endsWith('"')) v = v.substring(1, v.length - 1);
|
||||
|
||||
if (k === 'title') settings.docTitle = v;
|
||||
if (k === 'author') settings.author = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
|
||||
function getInitials(name: string) {
|
||||
return name.substring(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
let isMenuOpen = $state(false);
|
||||
let activeMenu = $state<string | null>(null);
|
||||
let showInfoModal = $state(false);
|
||||
let showRenameModal = $state(false);
|
||||
let showDeleteModal = $state(false);
|
||||
let renameTitle = $state("");
|
||||
$effect(() => { renameTitle = title; });
|
||||
let docInfo = $state<any>(null);
|
||||
|
||||
|
||||
function openInfo() {
|
||||
fetch(`/api/docs/${docId}`).then(res => res.json()).then(doc => {
|
||||
docInfo = doc;
|
||||
showInfoModal = true;
|
||||
}).catch(e => {
|
||||
console.error(e);
|
||||
showInfoModal = true;
|
||||
});
|
||||
}
|
||||
|
||||
function openRename() {
|
||||
renameTitle = title;
|
||||
showRenameModal = true;
|
||||
}
|
||||
|
||||
function submitRename(e: Event) {
|
||||
e.preventDefault();
|
||||
if (renameTitle && renameTitle !== title) {
|
||||
fetch(`/api/docs/${docId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: renameTitle })
|
||||
}).then(res => {
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
showRenameModal = false;
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
fetch(`/api/docs/${docId}`, { method: 'DELETE' }).then(res => {
|
||||
if (res.ok) goto('/dashboard');
|
||||
});
|
||||
}
|
||||
|
||||
function deleteDoc() {
|
||||
showDeleteModal = true;
|
||||
}
|
||||
|
||||
function handleWindowClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.action-menu-container')) {
|
||||
isMenuOpen = false;
|
||||
activeMenu = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
|
||||
<header class="flex flex-col border-b border-gray-200 dark:border-white/10 bg-white/80 dark:bg-black/20 backdrop-blur-md select-none w-full relative z-[60]">
|
||||
|
||||
<div class="flex items-center justify-between px-4 py-2.5">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
onclick={() => goto('/dashboard')}
|
||||
class="p-1.5 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
|
||||
aria-label="Back to dashboard"
|
||||
title="Dashboard"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-[16px] font-semibold text-gray-900 dark:text-white tracking-tight truncate max-w-[200px] md:max-w-xs" title={title}>
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
<div class="flex items-center gap-1.5 ml-2 px-2 py-0.5 rounded-full text-[11px] font-medium {
|
||||
$connectionStatus === 'connected' ? 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-900/20 dark:text-emerald-400 dark:border-emerald-800/30' :
|
||||
'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800/30'
|
||||
} border">
|
||||
<div class="w-1.5 h-1.5 rounded-full {$connectionStatus === 'connected' ? 'bg-emerald-500 shadow-[0_0_4px_rgba(16,185,129,0.4)]' : 'bg-amber-500 animate-pulse'}"></div>
|
||||
{$connectionStatus === 'connected' ? 'Synced' : 'Connecting...'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex items-center gap-0.5 text-[13px] font-medium text-gray-600 dark:text-gray-300 -ml-1 action-menu-container">
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'file' ? null : 'file'; }}
|
||||
class="px-2 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-white/10 transition-colors {activeMenu === 'file' ? 'bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-white' : ''}"
|
||||
>
|
||||
File
|
||||
</button>
|
||||
{#if activeMenu === 'file'}
|
||||
<div class="absolute left-0 top-full mt-1 w-48 bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-white/10 py-1 z-[100]">
|
||||
<button onclick={() => { activeMenu = null; goto('/dashboard'); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">New / Open</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; openRename(); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Rename</button>
|
||||
<button onclick={() => { activeMenu = null; isShareModalOpen = true; }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Share</button>
|
||||
<button onclick={() => { activeMenu = null; openInfo(); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Document Info</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; isPageSettingsOpen = true; }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Page Settings</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<div class="px-4 py-1.5 text-xs font-semibold text-gray-400 uppercase tracking-wider">Download</div>
|
||||
<button onclick={() => { activeMenu = null; handleExport('typ'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">.typ source</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('pdf'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">.pdf document</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('png'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">.png image</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('svg'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">.svg graphics</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; deleteDoc(); }} class="w-full text-left px-4 py-1.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/10">Delete</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'edit' ? null : 'edit'; }}
|
||||
class="px-2 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-white/10 transition-colors {activeMenu === 'edit' ? 'bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-white' : ''}"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{#if activeMenu === 'edit'}
|
||||
<div class="absolute left-0 top-full mt-1 w-48 bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-white/10 py-1 z-[100]">
|
||||
<button onclick={() => { activeMenu = null; document.execCommand('undo'); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Undo (Ctrl+Z)</button>
|
||||
<button onclick={() => { activeMenu = null; document.execCommand('redo'); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Redo (Ctrl+Y)</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; document.execCommand('cut'); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Cut (Ctrl+X)</button>
|
||||
<button onclick={() => { activeMenu = null; document.execCommand('copy'); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Copy (Ctrl+C)</button>
|
||||
<button onclick={() => { activeMenu = null; navigator.clipboard.readText().then(t => document.execCommand('insertText', false, t)); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Paste (Ctrl+V)</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'view' ? null : 'view'; }}
|
||||
class="px-2 py-0.5 rounded hover:bg-gray-100 dark:hover:bg-white/10 transition-colors {activeMenu === 'view' ? 'bg-gray-100 dark:bg-white/10 text-gray-900 dark:text-white' : ''}"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
{#if activeMenu === 'view'}
|
||||
<div class="absolute left-0 top-full mt-1 w-48 bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-white/10 py-1 z-[100]">
|
||||
<button onclick={() => { activeMenu = null; $darkModeStore = !$darkModeStore; document.documentElement.classList.toggle('dark', $darkModeStore); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5 flex items-center justify-between">
|
||||
Dark Mode
|
||||
<Icon icon={$darkModeStore ? "mdi:check" : ""} class="text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
{#if $connectedUsers.length > 0}
|
||||
<div class="flex items-center -space-x-2 mr-2">
|
||||
{#each $connectedUsers as user}
|
||||
<div
|
||||
class="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold text-white border-2 border-white dark:border-zinc-950 shadow-sm"
|
||||
style="background-color: {user.color}; z-index: {user.isLocal ? 10 : 1};"
|
||||
title={user.name + (user.isLocal ? ' (You)' : '')}
|
||||
>
|
||||
{getInitials(user.name)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
<div class="flex items-center gap-1.5 px-2">
|
||||
<a href="https://typst.app/docs/" target="_blank" rel="noopener noreferrer" class="p-1.5 text-gray-500 hover:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 rounded-md hover:bg-gray-100 dark:hover:bg-white/10 transition-colors" title="Typst Docs">
|
||||
<Icon icon="mdi:book-open-page-variant-outline" class="text-[18px]" />
|
||||
</a>
|
||||
<a href="https://typst.app/universe/" target="_blank" rel="noopener noreferrer" class="p-1.5 text-gray-500 hover:text-purple-500 dark:text-gray-400 dark:hover:text-purple-400 rounded-md hover:bg-gray-100 dark:hover:bg-white/10 transition-colors" title="Typst Universe">
|
||||
<Icon icon="mdi:earth" class="text-[18px]" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="w-px h-5 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
|
||||
<button
|
||||
onclick={() => (isShareModalOpen = true)}
|
||||
class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 dark:text-gray-200 dark:bg-black/20 dark:hover:bg-white/10 rounded-md transition-colors"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" x2="12" y1="2" y2="15"/></svg>
|
||||
Share
|
||||
</button>
|
||||
|
||||
<div class="w-px h-5 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
<div class="flex items-center bg-gray-100/50 dark:bg-zinc-900/50 rounded-md p-0.5 border border-gray-200 dark:border-white/10">
|
||||
<button onclick={() => handleExport('typ')} class="px-2.5 py-1 text-xs font-semibold text-gray-600 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-all" title="Download .typ source">TYP</button>
|
||||
<button onclick={() => handleExport('svg')} class="px-2.5 py-1 text-xs font-semibold text-gray-600 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-all" title="Export as SVG">SVG</button>
|
||||
<button onclick={() => handleExport('png')} class="px-2.5 py-1 text-xs font-semibold text-gray-600 hover:text-gray-900 hover:bg-white dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-all" title="Export as PNG">PNG</button>
|
||||
<button onclick={() => handleExport('pdf')} class="flex items-center gap-1 px-3 py-1 text-xs font-bold text-blue-600 bg-blue-50 hover:bg-blue-100 dark:text-blue-400 dark:bg-blue-900/20 dark:hover:bg-blue-900/40 rounded transition-all">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><path d="M8 13h2"/><path d="M8 17h2"/><path d="M14 13h2"/><path d="M14 17h2"/></svg>
|
||||
PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex items-center px-4 py-1.5 bg-white/50 dark:bg-black/10 border-t border-gray-200/60 dark:border-white/10 gap-4 overflow-x-auto no-scrollbar">
|
||||
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<button onclick={() => applyFormat('*', '*', 'bold')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Bold">
|
||||
<Icon icon="mdi:format-bold" class="text-lg" />
|
||||
</button>
|
||||
<button onclick={() => applyFormat('_', '_', 'italic')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Italic">
|
||||
<Icon icon="mdi:format-italic" class="text-lg" />
|
||||
</button>
|
||||
<button onclick={() => applyFormat('`', '`', 'code')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Code">
|
||||
<Icon icon="mdi:code-tags" class="text-lg" />
|
||||
</button>
|
||||
<div class="w-px h-4 mx-1 bg-gray-300 dark:bg-white/10"></div>
|
||||
<button onclick={() => applyFormat('$ ', ' $', 'x = y')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Math (Inline)">
|
||||
<Icon icon="mdi:sigma" class="text-lg" />
|
||||
</button>
|
||||
<button onclick={() => applyFormat('$ \n ', '\n$ ', 'x = y')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Math (Block)">
|
||||
<Icon icon="mdi:math-integral" class="text-lg" />
|
||||
</button>
|
||||
<div class="w-px h-4 mx-1 bg-gray-300 dark:bg-white/10"></div>
|
||||
<button onclick={() => applyFormat('- ', '', 'List item')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Bullet List">
|
||||
<Icon icon="mdi:format-list-bulleted" class="text-lg" />
|
||||
</button>
|
||||
<button onclick={() => applyFormat('+ ', '', 'Numbered item')} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Numbered List">
|
||||
<Icon icon="mdi:format-list-numbered" class="text-lg" />
|
||||
</button>
|
||||
<div class="w-px h-4 mx-1 bg-gray-300 dark:bg-white/10"></div>
|
||||
<input type="file" bind:this={fileInput} onchange={handleImageUpload} class="hidden" accept="image/*,.ttf,.otf" />
|
||||
<button onclick={() => fileInput.click()} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Upload Image / Font">
|
||||
<Icon icon="mdi:image-plus" class="text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="font-select" class="text-[11px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">Font</label>
|
||||
<select
|
||||
id="font-select"
|
||||
onchange={(e) => insertTypstConfig('text', `font: "${e.currentTarget.value}"`)}
|
||||
class="bg-white dark:bg-black/20 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-white/20 text-xs rounded shadow-sm focus:ring-blue-500 focus:border-blue-500 block py-1 pl-2 pr-6 appearance-none cursor-pointer hover:border-gray-400 dark:hover:border-zinc-600 transition-colors"
|
||||
>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="New Computer Modern">Default (New CM)</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="Libertinus Serif">Libertinus Serif</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="PT Sans">PT Sans</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="Roboto">Roboto</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
onclick={() => (isPageSettingsOpen = true)}
|
||||
class="flex items-center gap-1.5 px-3 py-1 text-[11px] font-semibold text-gray-600 hover:text-gray-900 bg-white hover:bg-gray-100 border border-gray-300 rounded shadow-sm dark:text-gray-300 dark:bg-black/20 dark:border-white/20 dark:hover:bg-white/10 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<Icon icon="mdi:file-document-edit-outline" class="text-sm" />
|
||||
Page Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
<div class="flex items-center gap-1 bg-white dark:bg-black/20 border border-gray-300 dark:border-white/20 rounded shadow-sm overflow-hidden">
|
||||
<button
|
||||
onclick={() => $documentZoomStore = Math.max(10, $documentZoomStore - 10)}
|
||||
class="px-2 py-1 text-gray-600 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-300 dark:hover:text-white dark:hover:bg-white/10 transition-colors"
|
||||
title="Zoom Out"
|
||||
>
|
||||
<Icon icon="mdi:minus" class="text-sm" />
|
||||
</button>
|
||||
<span role="button" tabindex="0" onkeydown={(e) => { if (e.key === "Enter") $documentZoomStore = 100; }} class="text-[11px] font-semibold text-gray-700 dark:text-gray-200 min-w-[3rem] text-center select-none" ondblclick={() => $documentZoomStore = 100}>
|
||||
{$documentZoomStore}%
|
||||
</span>
|
||||
<button
|
||||
onclick={() => $documentZoomStore = Math.min(500, $documentZoomStore + 10)}
|
||||
class="px-2 py-1 text-gray-600 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-300 dark:hover:text-white dark:hover:bg-white/10 transition-colors"
|
||||
title="Zoom In"
|
||||
>
|
||||
<Icon icon="mdi:plus" class="text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<ThemePicker />
|
||||
</div>
|
||||
|
||||
<div class="flex-grow"></div>
|
||||
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if isShareModalOpen}
|
||||
<ShareModal onClose={() => (isShareModalOpen = false)} />
|
||||
{/if}
|
||||
|
||||
{#if isPageSettingsOpen}
|
||||
<PageSettingsModal onClose={() => (isPageSettingsOpen = false)} onApply={handlePageSettings} currentSettings={parseSettings()} />
|
||||
{/if}
|
||||
|
||||
{#if showInfoModal}
|
||||
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4 transition-opacity" onclick={() => showInfoModal = false} role="presentation" onkeydown={(e) => { if (e.key === "Enter") { showInfoModal = false; } }}>
|
||||
<div class="bg-white/90 dark:bg-black/80 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/20 dark:border-white/10 w-full max-w-sm overflow-hidden transform transition-all" onclick={(e) => e.stopPropagation()} role="dialog" tabindex="-1" onkeydown={(e) => e.stopPropagation()}>
|
||||
<div class="p-6 border-b border-gray-100 dark:border-white/10">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-lg">
|
||||
<Icon icon="mdi:file-document" class="text-xl" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex-grow truncate">{docInfo?.title || title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Type</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100 capitalize">Document</p>
|
||||
</div>
|
||||
{#if docInfo?.created_at}
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Created At</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100">{new Date(docInfo.created_at).toLocaleString()}</p>
|
||||
</div>
|
||||
{/if}
|
||||
{#if docInfo?.updated_at}
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Last Modified</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100">{new Date(docInfo.updated_at).toLocaleString()}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="p-4 bg-gray-50 dark:bg-white/5 border-t border-gray-100 dark:border-white/10 flex justify-end">
|
||||
<button type="button" onclick={() => showInfoModal = false} class="px-5 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showRenameModal}
|
||||
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4 transition-opacity" onclick={() => showRenameModal = false} role="presentation" onkeydown={(e) => { if (e.key === "Enter") { showRenameModal = false; } }}>
|
||||
<div class="bg-white/90 dark:bg-black/80 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/20 dark:border-white/10 w-full max-w-sm overflow-hidden transform transition-all" onclick={(e) => e.stopPropagation()} role="dialog" tabindex="-1" onkeydown={(e) => e.stopPropagation()}>
|
||||
<form onsubmit={submitRename} class="p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="p-2 bg-yellow-50 dark:bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-lg">
|
||||
<Icon icon="mdi:pencil-outline" class="text-xl" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Rename</h3>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
bind:value={renameTitle}
|
||||
class="w-full bg-transparent border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white text-sm rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
placeholder="Enter new name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-6 flex justify-end gap-3">
|
||||
<button type="button" onclick={() => showRenameModal = false} class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showDeleteModal}
|
||||
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4 transition-opacity" onclick={() => showDeleteModal = false} role="presentation" onkeydown={(e) => { if (e.key === "Enter") { showDeleteModal = false; } }}>
|
||||
<div class="bg-white/90 dark:bg-black/80 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/20 dark:border-white/10 w-full max-w-sm overflow-hidden transform transition-all" onclick={(e) => e.stopPropagation()} role="dialog" tabindex="-1" onkeydown={(e) => e.stopPropagation()}>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="p-2 bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400 rounded-lg">
|
||||
<Icon icon="mdi:trash-can-outline" class="text-xl" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Delete Document</h3>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm mb-6">
|
||||
Are you sure you want to delete this document? This action cannot be undone.
|
||||
</p>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick={() => showDeleteModal = false} class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" onclick={confirmDelete} class="bg-red-600 hover:bg-red-700 text-white px-5 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
let { createDoc, onClose } = $props<{
|
||||
createDoc: (title: string) => void,
|
||||
onClose: () => void
|
||||
}>();
|
||||
|
||||
let newDocTitle = $state('Untitled Document');
|
||||
|
||||
function onSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
createDoc(newDocTitle);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200" role="presentation" onclick={onClose}>
|
||||
<div tabindex="-1" class="bg-white/80 dark:bg-black/40 backdrop-blur-xl rounded-xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-md overflow-hidden" role="dialog" aria-modal="true" aria-labelledby="create-doc-title" onclick={(e) => e.stopPropagation()} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
|
||||
<div class="flex justify-between items-center p-5 border-b border-gray-100 dark:border-white/10">
|
||||
<h2 id="create-doc-title" class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Icon icon="mdi:file-document-plus" class="text-blue-500 text-xl" />
|
||||
Create Document
|
||||
</h2>
|
||||
<button onclick={onClose} aria-label="Close" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-full p-1 transition-colors">
|
||||
<Icon icon="mdi:close" class="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onsubmit={onSubmit} class="p-5 space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label for="doc-title-input" class="text-sm font-medium text-gray-700 dark:text-gray-300 block">Document Title</label>
|
||||
<input
|
||||
id="doc-title-input"
|
||||
type="text"
|
||||
required
|
||||
bind:value={newDocTitle}
|
||||
class="w-full bg-transparent border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white text-sm rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
placeholder="Untitled Document"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 flex justify-end gap-3">
|
||||
<button type="button" onclick={onClose} class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm flex items-center gap-2">
|
||||
<Icon icon="mdi:plus" class="text-lg" />
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
let { createFolder, onClose } = $props<{
|
||||
createFolder: (name: string) => void,
|
||||
onClose: () => void
|
||||
}>();
|
||||
|
||||
let newFolderName = $state('New Folder');
|
||||
|
||||
function onSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
createFolder(newFolderName);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200" role="presentation" onclick={onClose}>
|
||||
<div tabindex="-1" class="bg-white/80 dark:bg-black/40 backdrop-blur-xl rounded-xl shadow-2xl border border-gray-200 dark:border-white/10 w-full max-w-md overflow-hidden" role="dialog" aria-modal="true" aria-labelledby="create-folder-title" onclick={(e) => e.stopPropagation()} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
|
||||
<div class="flex justify-between items-center p-5 border-b border-gray-100 dark:border-white/10">
|
||||
<h2 id="create-folder-title" class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Icon icon="mdi:folder-plus" class="text-yellow-500 text-xl" />
|
||||
Create Folder
|
||||
</h2>
|
||||
<button onclick={onClose} aria-label="Close" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-full p-1 transition-colors">
|
||||
<Icon icon="mdi:close" class="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onsubmit={onSubmit} class="p-5 space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label for="folder-title-input" class="text-sm font-medium text-gray-700 dark:text-gray-300 block">Folder Name</label>
|
||||
<input
|
||||
id="folder-title-input"
|
||||
type="text"
|
||||
required
|
||||
bind:value={newFolderName}
|
||||
class="w-full bg-transparent border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white text-sm rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
placeholder="New Folder"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 flex justify-end gap-3">
|
||||
<button type="button" onclick={onClose} class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="bg-yellow-500 hover:bg-yellow-600 text-white px-5 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm flex items-center gap-2">
|
||||
<Icon icon="mdi:plus" class="text-lg" />
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
let { deleteTarget, confirmDelete, onClose } = $props<{
|
||||
deleteTarget: {id: string, type: 'document'|'folder'|'file', name: string},
|
||||
confirmDelete: () => void,
|
||||
onClose: () => void
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4 transition-opacity" onclick={onClose} role="presentation" onkeydown={(e) => { if (e.key === "Enter") { onClose(e); } }}>
|
||||
<div class="bg-white/90 dark:bg-black/80 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/20 dark:border-white/10 w-full max-w-sm overflow-hidden transform transition-all" onclick={(e) => e.stopPropagation()} role="dialog" tabindex="-1" onkeydown={(e) => e.stopPropagation()}>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="p-2 bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400 rounded-lg">
|
||||
<Icon icon="mdi:trash-can-outline" class="text-xl" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Delete {deleteTarget.type}</h3>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-600 dark:text-gray-300 text-sm mb-6">
|
||||
Are you sure you want to delete <span class="font-semibold text-gray-900 dark:text-white">{deleteTarget.name}</span>?
|
||||
{#if deleteTarget.type === 'folder'}This will also delete all of its contents.{/if}
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<button type="button" onclick={onClose} class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" onclick={confirmDelete} class="bg-red-600 hover:bg-red-700 text-white px-5 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let { doc, activeMenu, setActiveMenu, openInfo, openRename, shareItem, deleteDoc } = $props<{
|
||||
doc: any;
|
||||
activeMenu: string | null;
|
||||
setActiveMenu: (id: string | null) => void;
|
||||
openInfo: (doc: any, type: string) => void;
|
||||
openRename: (id: string, title: string, type: 'document'|'folder'|'file') => void;
|
||||
shareItem: (item: any) => void;
|
||||
deleteDoc: (id: string, name: string) => void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 flex flex-col hover:shadow-lg hover:border-blue-400 dark:hover:border-blue-500/50 transition-all duration-200 relative group transform hover:-translate-y-1 cursor-pointer overflow-visible"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => goto(`/doc/${doc.id}`)}
|
||||
onkeydown={(e) => e.key === 'Enter' && goto(`/doc/${doc.id}`)}
|
||||
draggable="true"
|
||||
ondragstart={(e) => e.dataTransfer?.setData('text/plain', JSON.stringify({ type: 'document', id: doc.id }))}
|
||||
>
|
||||
|
||||
<div class="h-40 w-full bg-gray-50 dark:bg-black/40 rounded-t-xl overflow-hidden flex items-center justify-center border-b border-gray-100 dark:border-white/10 relative pointer-events-none">
|
||||
{#if doc.thumbnail_svg}
|
||||
<div class="w-full h-full flex items-center justify-center p-2 bg-white transition-transform duration-300 group-hover:scale-110">
|
||||
<img src={`data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(doc.thumbnail_svg)))}`} class="max-w-full max-h-full object-contain shadow-sm border border-gray-200" alt="Thumbnail" draggable="false" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-4 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full transition-transform duration-300 group-hover:scale-110">
|
||||
<Icon icon="mdi:file-document" class="text-4xl" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="p-4 flex flex-col flex-grow">
|
||||
<div class="flex items-start justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate pr-2 pointer-events-none" title={doc.title}>{doc.title}</h3>
|
||||
|
||||
|
||||
<div class="relative action-menu-container">
|
||||
<button
|
||||
aria-label="Document actions"
|
||||
onclick={(e) => { e.stopPropagation(); setActiveMenu(activeMenu === doc.id ? null : doc.id); }}
|
||||
class="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors p-1 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 pointer-events-auto"
|
||||
>
|
||||
<Icon icon="mdi:dots-vertical" class="text-xl" />
|
||||
</button>
|
||||
|
||||
{#if activeMenu === doc.id}
|
||||
<div class="absolute right-0 top-full mt-1 w-48 bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-white/10 py-1 z-[100]">
|
||||
<button onclick={(e) => { e.stopPropagation(); openInfo(doc, 'document'); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-2">
|
||||
<Icon icon="mdi:information-outline" class="text-lg text-blue-500" />
|
||||
View Info
|
||||
</button>
|
||||
<button onclick={(e) => { e.stopPropagation(); openRename(doc.id, doc.title, 'document'); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-2">
|
||||
<Icon icon="mdi:pencil-outline" class="text-lg text-yellow-500" />
|
||||
Rename
|
||||
</button>
|
||||
<button onclick={(e) => { e.stopPropagation(); shareItem(doc); }} class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5 flex items-center gap-2">
|
||||
<Icon icon="mdi:share-variant-outline" class="text-lg text-green-500" />
|
||||
Share
|
||||
</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={(e) => { e.stopPropagation(); deleteDoc(doc.id, doc.title); }} class="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/10 flex items-center gap-2">
|
||||
<Icon icon="mdi:trash-can-outline" class="text-lg" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 mt-2 pointer-events-none">
|
||||
<Icon icon="mdi:clock-outline" class="text-sm" />
|
||||
Edited {new Date(doc.updated_at.endsWith('Z') ? doc.updated_at : doc.updated_at + 'Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let { file, deleteFile } = $props<{
|
||||
file: any;
|
||||
deleteFile: (id: string, name: string) => void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-white dark:bg-black/20 rounded-xl shadow-sm border border-gray-200 dark:border-white/10 p-6 flex flex-col hover:shadow-lg hover:border-green-400 dark:hover:border-green-500/50 transition-all duration-200 relative group transform hover:-translate-y-1 cursor-pointer"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => window.open(`/api/files/${file.id}/data`, '_blank')}
|
||||
onkeydown={(e) => e.key === 'Enter' && window.open(`/api/files/${file.id}/data`, '_blank')}
|
||||
draggable="true"
|
||||
ondragstart={(e) => e.dataTransfer?.setData('text/plain', JSON.stringify({ type: 'file', id: file.id }))}
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4 pointer-events-none">
|
||||
<div class="p-3 bg-green-50 dark:bg-green-500/10 text-green-600 dark:text-green-400 rounded-lg overflow-hidden flex items-center justify-center w-12 h-12">
|
||||
{#if file.mime_type.startsWith('image/')}
|
||||
<img src={`/api/files/${file.id}/data`} alt={file.name} class="w-full h-full object-cover rounded" draggable="false" />
|
||||
{:else}
|
||||
<Icon icon="mdi:image-outline" class="text-2xl" />
|
||||
{/if}
|
||||
</div>
|
||||
<button aria-label="Delete file" onclick={(e) => { e.stopPropagation(); deleteFile(file.id, file.name); }} class="pointer-events-auto text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity bg-gray-50 hover:bg-red-50 dark:bg-white/5 dark:hover:bg-red-900/20 rounded-full p-2 shadow-sm border border-gray-100 dark:border-white/10">
|
||||
<Icon icon="mdi:trash-can-outline" class="text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white truncate mb-1 pointer-events-none" title={file.name}>{file.name}</h3>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1 mt-auto pt-4 border-t border-gray-100 dark:border-white/10 pointer-events-none">
|
||||
<Icon icon="mdi:clock-outline" class="text-sm" />
|
||||
Uploaded {new Date(file.created_at ? (file.created_at.endsWith('Z') ? file.created_at : file.created_at + 'Z') : Date.now()).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let { folder, dragOverFolderId, navigateToFolder, handleDrop, deleteFolder, setDragOverFolderId } = $props<{
|
||||
folder: any;
|
||||
dragOverFolderId: string | null;
|
||||
navigateToFolder: (folder: any) => void;
|
||||
handleDrop: (e: DragEvent, folderId: string) => void;
|
||||
deleteFolder: (id: string, name: string) => void;
|
||||
setDragOverFolderId: (id: string | null) => void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-white/5 cursor-pointer group transition-colors {dragOverFolderId === folder.id ? 'bg-blue-50 dark:bg-blue-900/20' : ''}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => navigateToFolder(folder)}
|
||||
onkeydown={(e) => e.key === 'Enter' && navigateToFolder(folder)}
|
||||
ondragover={(e) => { e.preventDefault(); setDragOverFolderId(folder.id); }}
|
||||
ondragleave={() => setDragOverFolderId(null)}
|
||||
ondrop={(e) => handleDrop(e, folder.id)}
|
||||
>
|
||||
<div class="flex items-center gap-3 pointer-events-none">
|
||||
<Icon icon="mdi:folder" class="text-2xl text-yellow-500" />
|
||||
<span class="font-medium text-gray-900 dark:text-white">{folder.name}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400 hidden sm:block pointer-events-none">
|
||||
{new Date(folder.created_at ? (folder.created_at.endsWith('Z') ? folder.created_at : folder.created_at + 'Z') : Date.now()).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
<button aria-label="Delete folder" onclick={(e) => { e.stopPropagation(); deleteFolder(folder.id, folder.name); }} class="text-gray-400 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity p-2 rounded-full hover:bg-red-50 dark:hover:bg-red-900/20">
|
||||
<Icon icon="mdi:trash-can-outline" class="text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
let { selectedInfo, onClose } = $props<{
|
||||
selectedInfo: {type: string, title?: string, name?: string, created_at: string, updated_at?: string},
|
||||
onClose: () => void
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 transition-opacity" onclick={onClose} role="presentation" onkeydown={(e) => { if (e.key === "Enter") { onClose(e); } }}>
|
||||
<div class="bg-white/90 dark:bg-black/80 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/20 dark:border-white/10 w-full max-w-sm overflow-hidden transform transition-all" onclick={(e) => e.stopPropagation()} role="dialog" tabindex="-1" onkeydown={(e) => e.stopPropagation()}>
|
||||
<div class="p-6 border-b border-gray-100 dark:border-white/10">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-lg">
|
||||
<Icon icon={selectedInfo.type === 'document' ? 'mdi:file-document' : selectedInfo.type === 'folder' ? 'mdi:folder' : 'mdi:file'} class="text-xl" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex-grow truncate">{selectedInfo.title || selectedInfo.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Type</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100 capitalize">{selectedInfo.type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Created At</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100">{new Date(selectedInfo.created_at.endsWith('Z') ? selectedInfo.created_at : selectedInfo.created_at + 'Z').toLocaleString()}</p>
|
||||
</div>
|
||||
{#if selectedInfo.updated_at}
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-1">Last Modified</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100">{new Date(selectedInfo.updated_at.endsWith('Z') ? selectedInfo.updated_at : selectedInfo.updated_at + 'Z').toLocaleString()}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="p-4 bg-gray-50 dark:bg-white/5 border-t border-gray-100 dark:border-white/10 flex justify-end">
|
||||
<button type="button" onclick={onClose} class="px-5 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { userStore } from '$lib/ts/auth';
|
||||
import Icon from '@iconify/svelte';
|
||||
import ThemePicker from '$lib/components/ThemePicker.svelte';
|
||||
|
||||
async function logout() {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
userStore.set(null);
|
||||
goto('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="bg-white/80 dark:bg-black/20 backdrop-blur-md shadow-sm border-b border-gray-200 dark:border-white/10 px-6 py-4 flex justify-between items-center sticky top-0 z-10 transition-colors duration-200">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||
<Icon icon="mdi:script-text" class="text-blue-600 dark:text-blue-400 text-3xl" />
|
||||
TypstDrive
|
||||
</h1>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="flex items-center gap-2 text-gray-700 dark:text-gray-300 font-medium">
|
||||
<Icon icon="mdi:account-circle" class="text-xl" />
|
||||
{$userStore?.username}
|
||||
</div>
|
||||
<div class="h-6 w-px bg-gray-300 dark:bg-white/20"></div>
|
||||
|
||||
|
||||
<a href="https://typst.app/docs/" target="_blank" rel="noopener noreferrer" class="text-sm font-medium text-gray-600 hover:text-blue-500 dark:text-gray-300 dark:hover:text-blue-400 transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-3 py-2 rounded-lg flex items-center gap-2 border border-transparent dark:border-white/10" title="Typst Docs">
|
||||
<Icon icon="mdi:book-open-page-variant-outline" class="text-xl" />
|
||||
</a>
|
||||
<a href="https://typst.app/universe/" target="_blank" rel="noopener noreferrer" class="text-sm font-medium text-gray-600 hover:text-purple-500 dark:text-gray-300 dark:hover:text-purple-400 transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-3 py-2 rounded-lg flex items-center gap-2 border border-transparent dark:border-white/10" title="Typst Universe">
|
||||
<Icon icon="mdi:earth" class="text-xl" />
|
||||
</a>
|
||||
<div class="h-6 w-px bg-gray-300 dark:bg-white/20"></div>
|
||||
|
||||
|
||||
<ThemePicker />
|
||||
|
||||
<button onclick={() => goto('/settings')} class="text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-3 py-2 rounded-lg flex items-center gap-2 border border-transparent dark:border-white/10" title="Settings">
|
||||
<Icon icon="mdi:cog" class="text-2xl" />
|
||||
</button>
|
||||
<button onclick={logout} class="text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white transition-colors bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 px-4 py-2 rounded-lg flex items-center gap-2 border border-transparent dark:border-white/10">
|
||||
<Icon icon="mdi:logout" class="text-lg" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script lang="ts">
|
||||
import Icon from '@iconify/svelte';
|
||||
let { initialTitle, handleRename, onClose } = $props<{
|
||||
initialTitle: string,
|
||||
handleRename: (newTitle: string) => void,
|
||||
onClose: () => void
|
||||
}>();
|
||||
|
||||
let renameTitle = $state("");
|
||||
$effect(() => { renameTitle = initialTitle; });
|
||||
|
||||
function onSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
handleRename(renameTitle);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 transition-opacity" onclick={onClose} role="presentation" onkeydown={(e) => { if (e.key === "Enter") { onClose(e); } }}>
|
||||
<div class="bg-white/90 dark:bg-black/80 backdrop-blur-xl rounded-2xl shadow-2xl border border-white/20 dark:border-white/10 w-full max-w-sm overflow-hidden transform transition-all" onclick={(e) => e.stopPropagation()} role="dialog" tabindex="-1" onkeydown={(e) => e.stopPropagation()}>
|
||||
<form onsubmit={onSubmit} class="p-6">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="p-2 bg-yellow-50 dark:bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-lg">
|
||||
<Icon icon="mdi:pencil-outline" class="text-xl" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Rename</h3>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
bind:value={renameTitle}
|
||||
class="w-full bg-transparent border border-gray-300 dark:border-white/20 text-gray-900 dark:text-white text-sm rounded-lg px-4 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors"
|
||||
placeholder="Enter new name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="pt-6 flex justify-end gap-3">
|
||||
<button type="button" onclick={onClose} class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-5 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user