Refactor journal UI and add markdown editor workflow
This commit is contained in:
parent
5167304cb4
commit
e4c156eb58
114
Journal.App/src/lib/components/AppModal.svelte
Normal file
114
Journal.App/src/lib/components/AppModal.svelte
Normal file
@ -0,0 +1,114 @@
|
||||
<script lang="ts">
|
||||
export let open = false;
|
||||
export let title = "";
|
||||
export let message = "";
|
||||
export let confirmText = "OK";
|
||||
export let cancelText = "Cancel";
|
||||
export let showCancel = false;
|
||||
export let tone: "default" | "danger" = "default";
|
||||
export let onConfirm: () => void = () => {};
|
||||
export let onCancel: () => void = () => {};
|
||||
|
||||
function handleConfirm() {
|
||||
onConfirm();
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
onCancel();
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (!open) return;
|
||||
if (event.key === "Escape") {
|
||||
handleCancel();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleWindowKeydown} />
|
||||
|
||||
{#if open}
|
||||
<div class="modal-backdrop">
|
||||
<dialog class="modal" open aria-label={title}>
|
||||
<header class="modal-header">
|
||||
<h2>{title}</h2>
|
||||
</header>
|
||||
|
||||
<p class="modal-message">{message}</p>
|
||||
|
||||
<div class="modal-actions">
|
||||
{#if showCancel}
|
||||
<button type="button" class="secondary" on:click={handleCancel}>{cancelText}</button>
|
||||
{/if}
|
||||
<button type="button" class:danger={tone === "danger"} on:click={handleConfirm}>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(7, 9, 12, 0.6);
|
||||
backdrop-filter: blur(2px);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 1000;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
margin: 0;
|
||||
width: min(420px, 100%);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface-1);
|
||||
box-shadow: 0 18px 46px rgba(0, 0, 0, 0.45);
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 0.98rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-message {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.86rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-actions button {
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border-soft);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-2);
|
||||
padding: 6px 11px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modal-actions button.secondary {
|
||||
background: var(--surface-1);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.modal-actions button.danger {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface-3);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
244
Journal.App/src/lib/components/CalendarWidget.svelte
Normal file
244
Journal.App/src/lib/components/CalendarWidget.svelte
Normal file
@ -0,0 +1,244 @@
|
||||
<script lang="ts">
|
||||
export let onVisibleMonthChange: (month: { year: number; month: number; label: string }) => void = () => {};
|
||||
export let onSelectedDateChange: (payload: { year: number; month: number; day: number; key: string }) => void =
|
||||
() => {};
|
||||
|
||||
const today = new Date();
|
||||
let currentYear = today.getFullYear();
|
||||
let currentMonth = today.getMonth();
|
||||
let selectedDateKey = getDateKey(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
|
||||
const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
type CalendarCell = {
|
||||
day: number;
|
||||
month: number;
|
||||
year: number;
|
||||
inMonth: boolean;
|
||||
isToday: boolean;
|
||||
isSelected: boolean;
|
||||
};
|
||||
|
||||
function getDateKey(year: number, month: number, day: number): string {
|
||||
const mm = String(month + 1).padStart(2, "0");
|
||||
const dd = String(day).padStart(2, "0");
|
||||
return `${year}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
function setViewDate(year: number, month: number) {
|
||||
const next = new Date(year, month, 1);
|
||||
currentYear = next.getFullYear();
|
||||
currentMonth = next.getMonth();
|
||||
}
|
||||
|
||||
function changeMonth(offset: number) {
|
||||
setViewDate(currentYear, currentMonth + offset);
|
||||
}
|
||||
|
||||
function selectCell(cell: CalendarCell) {
|
||||
if (!cell.inMonth) {
|
||||
setViewDate(cell.year, cell.month);
|
||||
}
|
||||
selectedDateKey = getDateKey(cell.year, cell.month, cell.day);
|
||||
}
|
||||
|
||||
function getCalendarCells(year: number, month: number): CalendarCell[] {
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const prevMonthLastDay = new Date(year, month, 0).getDate();
|
||||
const startOffset = (firstDay.getDay() + 6) % 7;
|
||||
const daysInMonth = lastDay.getDate();
|
||||
|
||||
const nextCells: CalendarCell[] = [];
|
||||
|
||||
for (let i = 0; i < startOffset; i += 1) {
|
||||
const day = prevMonthLastDay - startOffset + i + 1;
|
||||
const prevMonthDate = new Date(year, month - 1, day);
|
||||
const key = getDateKey(prevMonthDate.getFullYear(), prevMonthDate.getMonth(), day);
|
||||
nextCells.push({
|
||||
day,
|
||||
month: prevMonthDate.getMonth(),
|
||||
year: prevMonthDate.getFullYear(),
|
||||
inMonth: false,
|
||||
isToday: key === getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
isSelected: key === selectedDateKey
|
||||
});
|
||||
}
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day += 1) {
|
||||
const key = getDateKey(year, month, day);
|
||||
nextCells.push({
|
||||
day,
|
||||
month,
|
||||
year,
|
||||
inMonth: true,
|
||||
isToday: key === getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
isSelected: key === selectedDateKey
|
||||
});
|
||||
}
|
||||
|
||||
const trailing = (7 - (nextCells.length % 7)) % 7;
|
||||
for (let day = 1; day <= trailing; day += 1) {
|
||||
const nextMonthDate = new Date(year, month + 1, day);
|
||||
const key = getDateKey(nextMonthDate.getFullYear(), nextMonthDate.getMonth(), day);
|
||||
nextCells.push({
|
||||
day,
|
||||
month: nextMonthDate.getMonth(),
|
||||
year: nextMonthDate.getFullYear(),
|
||||
inMonth: false,
|
||||
isToday: key === getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
isSelected: key === selectedDateKey
|
||||
});
|
||||
}
|
||||
|
||||
return nextCells;
|
||||
}
|
||||
|
||||
$: monthLabel = new Date(currentYear, currentMonth, 1).toLocaleString(undefined, { month: "long" });
|
||||
$: cells = getCalendarCells(currentYear, currentMonth);
|
||||
$: onVisibleMonthChange({ year: currentYear, month: currentMonth, label: monthLabel });
|
||||
$: {
|
||||
const parts = selectedDateKey.split("-");
|
||||
const [year, month, day] = parts.map((value) => Number(value));
|
||||
if (parts.length === 3 && !Number.isNaN(year) && !Number.isNaN(month) && !Number.isNaN(day)) {
|
||||
onSelectedDateChange({ year, month: month - 1, day, key: selectedDateKey });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="calendar-widget" aria-label="Monthly calendar">
|
||||
<header class="calendar-header">
|
||||
<button type="button" class="nav-icon" aria-label="Previous month" on:click={() => changeMonth(-1)}>
|
||||
<span class="material-symbols-outlined">chevron_left</span>
|
||||
</button>
|
||||
|
||||
<div class="month-title">
|
||||
<h3>{monthLabel}</h3>
|
||||
<span>{currentYear}</span>
|
||||
</div>
|
||||
|
||||
<button type="button" class="nav-icon" aria-label="Next month" on:click={() => changeMonth(1)}>
|
||||
<span class="material-symbols-outlined">chevron_right</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="calendar-weekdays">
|
||||
{#each weekdays as weekday}
|
||||
<span>{weekday}</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="calendar-grid">
|
||||
{#each cells as cell}
|
||||
<button
|
||||
type="button"
|
||||
class="calendar-cell"
|
||||
class:is-muted={!cell.inMonth}
|
||||
class:is-today={cell.isToday}
|
||||
class:is-selected={cell.isSelected}
|
||||
aria-label={`Day ${cell.day}`}
|
||||
on:click={() => selectCell(cell)}
|
||||
>
|
||||
{cell.day}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.calendar-widget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-1);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.calendar-header {
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) 28px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 6px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-icon .material-symbols-outlined {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.nav-icon:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.month-title {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.month-title h3 {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.month-title span {
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.calendar-weekdays,
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.calendar-weekdays span {
|
||||
text-align: center;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.calendar-cell {
|
||||
height: 30px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.calendar-cell:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.calendar-cell.is-muted {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.calendar-cell.is-today {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-3);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.calendar-cell.is-selected {
|
||||
border-color: var(--zinc-300);
|
||||
box-shadow: inset 0 0 0 1px var(--zinc-300);
|
||||
}
|
||||
</style>
|
||||
431
Journal.App/src/lib/components/EditorPanel.svelte
Normal file
431
Journal.App/src/lib/components/EditorPanel.svelte
Normal file
@ -0,0 +1,431 @@
|
||||
<script lang="ts">
|
||||
export let openDocumentId = "entries/daily-notes";
|
||||
export let openDocumentName = "Daily Notes";
|
||||
export let openDocumentContent = "";
|
||||
export let onDocumentContentChange: (content: string) => void = () => {};
|
||||
|
||||
let markdownText = openDocumentContent;
|
||||
let lastOpenDocumentId = openDocumentId;
|
||||
let previewOnly = false;
|
||||
let editorInput: HTMLTextAreaElement | null = null;
|
||||
|
||||
function updateDraft(value: string) {
|
||||
markdownText = value;
|
||||
onDocumentContentChange(value);
|
||||
}
|
||||
|
||||
function applyWrap(before: string, after = before) {
|
||||
if (!editorInput) return;
|
||||
const current = markdownText;
|
||||
const start = editorInput.selectionStart ?? 0;
|
||||
const end = editorInput.selectionEnd ?? start;
|
||||
const selected = current.slice(start, end);
|
||||
const insertion = `${before}${selected}${after}`;
|
||||
const next = `${current.slice(0, start)}${insertion}${current.slice(end)}`;
|
||||
markdownText = next;
|
||||
onDocumentContentChange(next);
|
||||
queueMicrotask(() => {
|
||||
if (!editorInput) return;
|
||||
const cursor = start + insertion.length;
|
||||
editorInput.focus();
|
||||
editorInput.setSelectionRange(cursor, cursor);
|
||||
});
|
||||
}
|
||||
|
||||
function applyLinePrefix(prefix: string) {
|
||||
if (!editorInput) return;
|
||||
const current = markdownText;
|
||||
const start = editorInput.selectionStart ?? 0;
|
||||
const end = editorInput.selectionEnd ?? start;
|
||||
const blockStart = current.lastIndexOf("\n", start - 1) + 1;
|
||||
const blockEndIndex = current.indexOf("\n", end);
|
||||
const blockEnd = blockEndIndex === -1 ? current.length : blockEndIndex;
|
||||
const block = current.slice(blockStart, blockEnd);
|
||||
const nextBlock = block
|
||||
.split("\n")
|
||||
.map((line) => `${prefix}${line}`)
|
||||
.join("\n");
|
||||
const next = `${current.slice(0, blockStart)}${nextBlock}${current.slice(blockEnd)}`;
|
||||
markdownText = next;
|
||||
onDocumentContentChange(next);
|
||||
}
|
||||
|
||||
function applyHeading(level: number) {
|
||||
if (!Number.isFinite(level) || level < 1 || level > 6) return;
|
||||
applyLinePrefix(`${"#".repeat(level)} `);
|
||||
}
|
||||
|
||||
function insertLink() {
|
||||
applyWrap("[", "](https://example.com)");
|
||||
}
|
||||
|
||||
function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function parseInline(input: string): string {
|
||||
let value = escapeHtml(input);
|
||||
value = value.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
value = value.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
value = value.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||||
value = value.replace(
|
||||
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
|
||||
'<a href="$2" target="_blank" rel="noreferrer">$1</a>'
|
||||
);
|
||||
return value;
|
||||
}
|
||||
|
||||
function renderMarkdown(markdown: string): string {
|
||||
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||||
const output: string[] = [];
|
||||
let i = 0;
|
||||
let inCode = false;
|
||||
let codeLines: string[] = [];
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith("```")) {
|
||||
if (inCode) {
|
||||
output.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
||||
codeLines = [];
|
||||
inCode = false;
|
||||
} else {
|
||||
inCode = true;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inCode) {
|
||||
codeLines.push(line);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!trimmed) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (heading) {
|
||||
const level = heading[1].length;
|
||||
output.push(`<h${level}>${parseInline(heading[2])}</h${level}>`);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^[-*+]\s+/.test(trimmed)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^[-*+]\s+/.test(lines[i].trim())) {
|
||||
items.push(`<li>${parseInline(lines[i].trim().replace(/^[-*+]\s+/, ""))}</li>`);
|
||||
i += 1;
|
||||
}
|
||||
output.push(`<ul>${items.join("")}</ul>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\d+\.\s+/.test(trimmed)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^\d+\.\s+/.test(lines[i].trim())) {
|
||||
items.push(`<li>${parseInline(lines[i].trim().replace(/^\d+\.\s+/, ""))}</li>`);
|
||||
i += 1;
|
||||
}
|
||||
output.push(`<ol>${items.join("")}</ol>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^>\s+/.test(trimmed)) {
|
||||
output.push(`<blockquote>${parseInline(trimmed.replace(/^>\s+/, ""))}</blockquote>`);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^(-{3,}|\*{3,})$/.test(trimmed)) {
|
||||
output.push("<hr />");
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const paragraph: string[] = [];
|
||||
while (i < lines.length && lines[i].trim()) {
|
||||
paragraph.push(lines[i].trim());
|
||||
i += 1;
|
||||
}
|
||||
output.push(`<p>${parseInline(paragraph.join(" "))}</p>`);
|
||||
}
|
||||
|
||||
if (inCode) {
|
||||
output.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
||||
}
|
||||
|
||||
return output.join("");
|
||||
}
|
||||
|
||||
function extractEditorTitle(markdown: string): string {
|
||||
const firstLine = markdown.replace(/\r\n/g, "\n").split("\n")[0]?.trim() ?? "";
|
||||
const headingMatch = firstLine.match(/^#\s+(.+)$/);
|
||||
return headingMatch ? headingMatch[1] : openDocumentName;
|
||||
}
|
||||
|
||||
$: if (openDocumentId !== lastOpenDocumentId) {
|
||||
markdownText = openDocumentContent;
|
||||
lastOpenDocumentId = openDocumentId;
|
||||
}
|
||||
|
||||
$: editorTitle = extractEditorTitle(markdownText);
|
||||
$: renderedHtml = renderMarkdown(markdownText);
|
||||
</script>
|
||||
|
||||
<main class="editor-panel" aria-label="Editor area">
|
||||
<header class="editor-header">
|
||||
<h1>{editorTitle}</h1>
|
||||
<div class="editor-actions">
|
||||
<button type="button" class:primary={!previewOnly} on:click={() => (previewOnly = false)}>Write</button>
|
||||
<button type="button" class:primary={previewOnly} on:click={() => (previewOnly = true)}>Preview</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="editor-surface" class:preview-only={previewOnly}>
|
||||
{#if !previewOnly}
|
||||
<div class="editor-toolbar">
|
||||
<select
|
||||
class="toolbar-select"
|
||||
aria-label="Header size"
|
||||
on:change={(event) => {
|
||||
const target = event.currentTarget as HTMLSelectElement;
|
||||
const level = Number(target.value);
|
||||
if (level) applyHeading(level);
|
||||
target.value = "";
|
||||
}}
|
||||
>
|
||||
<option value="">Heading</option>
|
||||
<option value="1">H1</option>
|
||||
<option value="2">H2</option>
|
||||
<option value="3">H3</option>
|
||||
<option value="4">H4</option>
|
||||
<option value="5">H5</option>
|
||||
<option value="6">H6</option>
|
||||
</select>
|
||||
<button type="button" on:click={() => applyWrap("**")}>Bold</button>
|
||||
<button type="button" on:click={() => applyWrap("*")}>Italic</button>
|
||||
<button type="button" on:click={insertLink}>Link</button>
|
||||
<button type="button" on:click={() => applyLinePrefix("- ")}>UL</button>
|
||||
<button type="button" on:click={() => applyLinePrefix("1. ")}>OL</button>
|
||||
<button type="button" on:click={() => applyWrap("`")}>Code</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="editor-workspace">
|
||||
{#if previewOnly}
|
||||
<article class="markdown-preview" aria-label="Markdown preview">
|
||||
{@html renderedHtml}
|
||||
</article>
|
||||
{:else}
|
||||
<textarea
|
||||
bind:this={editorInput}
|
||||
class="markdown-input"
|
||||
bind:value={markdownText}
|
||||
on:input={(event) => updateDraft((event.currentTarget as HTMLTextAreaElement).value)}
|
||||
aria-label="Markdown input"
|
||||
></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.editor-panel {
|
||||
background: linear-gradient(180deg, var(--surface-2) 0%, var(--bg-editor) 100%);
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.editor-header h1 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-actions button {
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border-soft);
|
||||
padding: 6px 11px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor-actions button:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.editor-actions button.primary {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface-3);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.editor-actions button.primary:hover {
|
||||
background: var(--bg-active);
|
||||
}
|
||||
|
||||
.editor-surface {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-1);
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-surface.preview-only {
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-app);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.editor-toolbar button {
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-muted);
|
||||
padding: 5px 8px;
|
||||
font-size: 0.74rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toolbar-select {
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-2);
|
||||
color: var(--text-muted);
|
||||
padding: 5px 8px;
|
||||
font-size: 0.74rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor-toolbar button:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toolbar-select:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.editor-workspace {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.markdown-input,
|
||||
.markdown-preview {
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-app);
|
||||
color: var(--text-primary);
|
||||
padding: 12px;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.5;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.markdown-input {
|
||||
display: block;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.markdown-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.markdown-preview :global(h1),
|
||||
.markdown-preview :global(h2),
|
||||
.markdown-preview :global(h3),
|
||||
.markdown-preview :global(h4),
|
||||
.markdown-preview :global(h5),
|
||||
.markdown-preview :global(h6) {
|
||||
margin: 0 0 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.markdown-preview :global(p),
|
||||
.markdown-preview :global(blockquote),
|
||||
.markdown-preview :global(pre),
|
||||
.markdown-preview :global(ul),
|
||||
.markdown-preview :global(ol) {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.markdown-preview :global(ul),
|
||||
.markdown-preview :global(ol) {
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.markdown-preview :global(code) {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
font-family: Consolas, "Courier New", monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.markdown-preview :global(pre code) {
|
||||
display: block;
|
||||
padding: 8px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.markdown-preview :global(blockquote) {
|
||||
border-left: 3px solid var(--border-strong);
|
||||
padding-left: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.markdown-preview :global(a) {
|
||||
color: var(--text-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
271
Journal.App/src/lib/components/Navbar.svelte
Normal file
271
Journal.App/src/lib/components/Navbar.svelte
Normal file
@ -0,0 +1,271 @@
|
||||
<script lang="ts">
|
||||
export let activeSection: string | null = "entries";
|
||||
export let onSelect: (id: string) => void = () => {};
|
||||
|
||||
type NavItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
const workspaceItems: NavItem[] = [
|
||||
{ id: "entries", label: "Entries", icon: "menu_book" },
|
||||
{ id: "calendar", label: "Calendar", icon: "calendar_month" },
|
||||
{ id: "fragments", label: "Fragments", icon: "auto_stories" },
|
||||
{ id: "todos", label: "To-Do List", icon: "checklist" },
|
||||
{ id: "lists", label: "Lists", icon: "lists" }
|
||||
];
|
||||
|
||||
const profileItems: NavItem[] = [
|
||||
{ id: "account", label: "Account", icon: "account_circle" },
|
||||
{ id: "settings", label: "Settings", icon: "settings" },
|
||||
{ id: "logout", label: "Logout", icon: "logout" }
|
||||
];
|
||||
|
||||
let profileMenuOpen = false;
|
||||
|
||||
function selectItem(id: string) {
|
||||
onSelect(id);
|
||||
profileMenuOpen = false;
|
||||
}
|
||||
|
||||
function handleProfileItemClick(item: NavItem) {
|
||||
selectItem(item.id);
|
||||
}
|
||||
|
||||
function toggleProfileMenu() {
|
||||
profileMenuOpen = !profileMenuOpen;
|
||||
}
|
||||
|
||||
function closeProfileMenu() {
|
||||
profileMenuOpen = false;
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
closeProfileMenu();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={closeProfileMenu} on:keydown={handleWindowKeydown} />
|
||||
|
||||
<aside class="navbar" aria-label="Primary navigation">
|
||||
<div class="navbar-header">
|
||||
<img src="svelte.svg" alt="Journal logo" />
|
||||
</div>
|
||||
|
||||
<nav class="nav-groups" aria-label="Journal sections">
|
||||
<div class="nav-group">
|
||||
{#each workspaceItems as item}
|
||||
<button
|
||||
type="button"
|
||||
class="nav-button"
|
||||
class:is-active={activeSection === item.id}
|
||||
on:click={() => selectItem(item.id)}
|
||||
aria-label={item.label}
|
||||
>
|
||||
<span class="material-symbols-outlined">{item.icon}</span>
|
||||
<span class="nav-tooltip" role="tooltip">{item.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<button
|
||||
class="user-chip"
|
||||
type="button"
|
||||
class:is-active={profileMenuOpen}
|
||||
aria-label="Profile menu"
|
||||
on:click|stopPropagation={toggleProfileMenu}
|
||||
>
|
||||
<img src="https://placehold.co/800x600/09090b/jpg" alt="Profile" />
|
||||
<span class="nav-tooltip" role="tooltip">John Doe</span>
|
||||
</button>
|
||||
|
||||
{#if profileMenuOpen}
|
||||
<div class="profile-menu" role="menu" tabindex="-1">
|
||||
{#each profileItems as item}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
class="profile-menu-item"
|
||||
on:click={() => handleProfileItemClick(item)}
|
||||
>
|
||||
<span class="material-symbols-outlined">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.navbar {
|
||||
position: relative;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 10px;
|
||||
background: linear-gradient(180deg, var(--surface-2) 0%, var(--bg-navbar) 100%);
|
||||
border-right: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.navbar-header img {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
object-fit: cover;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--border-strong);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-groups {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-button,
|
||||
.user-chip {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
color: var(--text-dim);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.14s ease, color 0.14s ease, border-color 0.14s ease;
|
||||
}
|
||||
|
||||
.nav-button .material-symbols-outlined {
|
||||
font-size: 1.18rem;
|
||||
}
|
||||
|
||||
.nav-tooltip {
|
||||
position: absolute;
|
||||
left: calc(100% + 12px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%) translateX(-4px);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
padding: 4px 9px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border-strong);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.nav-button:hover,
|
||||
.nav-button:focus-visible,
|
||||
.user-chip:hover,
|
||||
.user-chip:focus-visible {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.nav-button:hover .nav-tooltip,
|
||||
.nav-button:focus-visible .nav-tooltip,
|
||||
.user-chip:hover .nav-tooltip,
|
||||
.user-chip:focus-visible .nav-tooltip {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
|
||||
.nav-button.is-active {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-active);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.nav-button.is-active .material-symbols-outlined {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.user-chip {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.user-chip img {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
|
||||
.profile-menu {
|
||||
position: absolute;
|
||||
left: calc(100% + 12px);
|
||||
bottom: 14px;
|
||||
min-width: 158px;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface-1);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.profile-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 8px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.profile-menu-item .material-symbols-outlined {
|
||||
font-size: 1rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.profile-menu-item:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-soft);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.profile-menu-item:hover .material-symbols-outlined {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.nav-button,
|
||||
.user-chip {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.profile-menu {
|
||||
left: calc(100% + 8px);
|
||||
bottom: 10px;
|
||||
min-width: 144px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
322
Journal.App/src/lib/components/SidePanel.svelte
Normal file
322
Journal.App/src/lib/components/SidePanel.svelte
Normal file
@ -0,0 +1,322 @@
|
||||
<script lang="ts">
|
||||
import CalendarWidget from "$lib/components/CalendarWidget.svelte";
|
||||
|
||||
export let activeSection = "entries";
|
||||
export let activeDocumentId = "";
|
||||
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
|
||||
|
||||
type SidePanelItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
initialContent: string;
|
||||
};
|
||||
|
||||
const sectionTitles: Record<string, string> = {
|
||||
entries: "Entries",
|
||||
calendar: "Calendar",
|
||||
fragments: "Fragments",
|
||||
todos: "To-Do List",
|
||||
lists: "Lists",
|
||||
account: "Account",
|
||||
settings: "Settings",
|
||||
logout: "Logout"
|
||||
};
|
||||
|
||||
const sectionItems: Record<string, SidePanelItem[]> = {
|
||||
entries: [
|
||||
{ id: "entries/daily-notes", label: "Daily Notes", initialContent: "# Daily Notes\n\nStart writing today's entry..." },
|
||||
{ id: "entries/ideas", label: "Ideas", initialContent: "# Ideas\n\nCapture ideas before they disappear." },
|
||||
{ id: "entries/archive", label: "Archive", initialContent: "# Archive\n\nOlder entries and references." }
|
||||
],
|
||||
fragments: [
|
||||
{ id: "fragments/highlights", label: "Highlights", initialContent: "# Highlights\n\nImportant highlights and excerpts." },
|
||||
{ id: "fragments/quotes", label: "Quotes", initialContent: "# Quotes\n\nQuotes worth revisiting." },
|
||||
{ id: "fragments/scratchpad", label: "Scratchpad", initialContent: "# Scratchpad\n\nTemporary notes and rough thoughts." }
|
||||
],
|
||||
todos: [
|
||||
{ id: "todos/today", label: "Today", initialContent: "# Today\n\n- [ ] Top priority\n- [ ] Secondary task" },
|
||||
{ id: "todos/scheduled", label: "Scheduled", initialContent: "# Scheduled\n\nTasks planned for later dates." },
|
||||
{ id: "todos/completed", label: "Completed", initialContent: "# Completed\n\nFinished tasks log." }
|
||||
],
|
||||
lists: [
|
||||
{ id: "lists/reading", label: "Reading", initialContent: "# Reading\n\nBooks and articles to read." },
|
||||
{ id: "lists/projects", label: "Projects", initialContent: "# Projects\n\nActive and planned projects." },
|
||||
{ id: "lists/someday", label: "Someday", initialContent: "# Someday\n\nLong-term ideas." }
|
||||
],
|
||||
account: [
|
||||
{ id: "account/profile", label: "Profile", initialContent: "# Profile\n\nAccount profile notes." },
|
||||
{ id: "account/appearance", label: "Appearance", initialContent: "# Appearance\n\nTheme and layout preferences." },
|
||||
{ id: "account/connections", label: "Connections", initialContent: "# Connections\n\nIntegrations and linked services." }
|
||||
],
|
||||
settings: [
|
||||
{ id: "settings/general", label: "General", initialContent: "# General Settings\n\nGeneral application settings." },
|
||||
{ id: "settings/hotkeys", label: "Hotkeys", initialContent: "# Hotkeys\n\nKeyboard shortcut configuration." },
|
||||
{ id: "settings/plugins", label: "Plugins", initialContent: "# Plugins\n\nPlugin management notes." }
|
||||
],
|
||||
logout: [{ id: "logout/confirm", label: "Confirm Logout", initialContent: "# Logout\n\nConfirm logout request." }]
|
||||
};
|
||||
|
||||
const today = new Date();
|
||||
let calendarYear = today.getFullYear();
|
||||
let calendarMonth = today.getMonth();
|
||||
let calendarMonthLabel = new Date(calendarYear, calendarMonth, 1).toLocaleString(undefined, {
|
||||
month: "long"
|
||||
});
|
||||
let selectedCalendarDate: { year: number; month: number; day: number; key: string } | null = {
|
||||
year: today.getFullYear(),
|
||||
month: today.getMonth(),
|
||||
day: today.getDate(),
|
||||
key: `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`
|
||||
};
|
||||
|
||||
function handleVisibleMonthChange(payload: { year: number; month: number; label: string }) {
|
||||
calendarYear = payload.year;
|
||||
calendarMonth = payload.month;
|
||||
calendarMonthLabel = payload.label;
|
||||
}
|
||||
|
||||
function toMonthKey(year: number, month: number): string {
|
||||
return `${year}-${String(month + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function toDateKey(year: number, month: number, day: number): string {
|
||||
return `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function handleSelectedDateChange(payload: { year: number; month: number; day: number; key: string }) {
|
||||
selectedCalendarDate = payload;
|
||||
}
|
||||
|
||||
function daysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
function getCalendarEntries(year: number, month: number): SidePanelItem[] {
|
||||
const monthKey = toMonthKey(year, month);
|
||||
const shortLabel = new Date(year, month, 1).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
year: "numeric"
|
||||
});
|
||||
const totalDays = daysInMonth(year, month);
|
||||
const anchors = [3, 10, 17, 24].map((day) => Math.min(day, totalDays));
|
||||
|
||||
const entries: Array<SidePanelItem & { day: number; dateKey: string }> = [
|
||||
{
|
||||
id: `calendar/${monthKey}/overview`,
|
||||
label: `${shortLabel} Overview`,
|
||||
initialContent: `# ${shortLabel} Overview\n\nMonthly highlights and notes.`,
|
||||
day: anchors[0],
|
||||
dateKey: toDateKey(year, month, anchors[0])
|
||||
},
|
||||
{
|
||||
id: `calendar/${monthKey}/goals`,
|
||||
label: `${shortLabel} Goals`,
|
||||
initialContent: `# ${shortLabel} Goals\n\n- Goal 1\n- Goal 2\n- Goal 3`,
|
||||
day: anchors[1],
|
||||
dateKey: toDateKey(year, month, anchors[1])
|
||||
},
|
||||
{
|
||||
id: `calendar/${monthKey}/review`,
|
||||
label: `${shortLabel} Review`,
|
||||
initialContent: `# ${shortLabel} Review\n\nWhat went well and what to improve next month.`,
|
||||
day: anchors[2],
|
||||
dateKey: toDateKey(year, month, anchors[2])
|
||||
},
|
||||
{
|
||||
id: `calendar/${monthKey}/notes`,
|
||||
label: `${shortLabel} Notes`,
|
||||
initialContent: `# ${shortLabel} Notes\n\nFreeform notes for this month.`,
|
||||
day: anchors[3],
|
||||
dateKey: toDateKey(year, month, anchors[3])
|
||||
}
|
||||
];
|
||||
|
||||
const selected = selectedCalendarDate;
|
||||
const isSelectedInVisibleMonth = selected && selected.year === year && selected.month === month;
|
||||
|
||||
if (!isSelectedInVisibleMonth || !selected) {
|
||||
return entries.sort((a, b) => a.day - b.day);
|
||||
}
|
||||
|
||||
const selectedDay = selected.day;
|
||||
return entries
|
||||
.sort((a, b) => {
|
||||
const distA = Math.abs(a.day - selectedDay);
|
||||
const distB = Math.abs(b.day - selectedDay);
|
||||
if (distA !== distB) return distA - distB;
|
||||
return a.day - b.day;
|
||||
})
|
||||
.map(({ day, dateKey, ...item }) => item);
|
||||
}
|
||||
|
||||
$: panelTitle = sectionTitles[activeSection] ?? "Entries";
|
||||
$: items = sectionItems[activeSection] ?? sectionItems.entries;
|
||||
$: isCalendarSection = activeSection === "calendar";
|
||||
$: calendarEntries = getCalendarEntries(calendarYear, calendarMonth);
|
||||
</script>
|
||||
|
||||
<section class="side-panel" aria-label="Section panel">
|
||||
<header class="panel-header">
|
||||
<h2>{panelTitle}</h2>
|
||||
<button type="button" class="panel-action" aria-label="Add item">
|
||||
<span class="material-symbols-outlined">add</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if isCalendarSection}
|
||||
<CalendarWidget
|
||||
onVisibleMonthChange={handleVisibleMonthChange}
|
||||
onSelectedDateChange={handleSelectedDateChange}
|
||||
/>
|
||||
|
||||
<div class="calendar-entries">
|
||||
<h3>{calendarMonthLabel} {calendarYear} Entries</h3>
|
||||
<ul class="panel-list">
|
||||
{#each calendarEntries as item}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class:is-active={item.id === activeDocumentId}
|
||||
on:click={() => onOpenDocument(item)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="panel-search">
|
||||
<span class="material-symbols-outlined">search</span>
|
||||
<input type="text" placeholder={`Search ${panelTitle.toLowerCase()}...`} />
|
||||
</div>
|
||||
|
||||
<ul class="panel-list">
|
||||
{#each items as item}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class:is-active={item.id === activeDocumentId}
|
||||
on:click={() => onOpenDocument(item)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.side-panel {
|
||||
background: linear-gradient(180deg, var(--surface-2) 0%, var(--bg-panel) 100%);
|
||||
border-right: 1px solid var(--border-soft);
|
||||
padding: 16px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.panel-action {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.panel-action:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-soft);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.panel-action .material-symbols-outlined {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.panel-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
padding: 7px 9px;
|
||||
}
|
||||
|
||||
.panel-search .material-symbols-outlined {
|
||||
color: var(--text-dim);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.panel-search input {
|
||||
width: 100%;
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.panel-search input::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.panel-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.panel-list li button {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border-radius: 7px;
|
||||
padding: 7px 9px;
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.panel-list li button:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.panel-list li button.is-active {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-active);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.calendar-entries {
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.calendar-entries h3 {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
</style>
|
||||
@ -1,72 +1,139 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { goto } from "$app/navigation";
|
||||
import AppModal from "$lib/components/AppModal.svelte";
|
||||
import Navbar from "$lib/components/Navbar.svelte";
|
||||
import SidePanel from "$lib/components/SidePanel.svelte";
|
||||
import EditorPanel from "$lib/components/EditorPanel.svelte";
|
||||
|
||||
let name = $state("");
|
||||
let greetMsg = $state("");
|
||||
type OpenDocument = {
|
||||
id: string;
|
||||
label: string;
|
||||
initialContent: string;
|
||||
};
|
||||
|
||||
async function greet(event: Event) {
|
||||
event.preventDefault();
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
greetMsg = await invoke("greet", { name });
|
||||
let selectedSection = "entries";
|
||||
let panelOpen = true;
|
||||
let activeDocumentId = "entries/daily-notes";
|
||||
let activeDocumentLabel = "Daily Notes";
|
||||
let openDocuments: Record<string, string> = {
|
||||
"entries/daily-notes": "# Daily Notes\n\nStart writing today's entry..."
|
||||
};
|
||||
let modalOpen = false;
|
||||
let modalTitle = "";
|
||||
let modalMessage = "";
|
||||
let modalConfirmText = "OK";
|
||||
let modalCancelText = "Cancel";
|
||||
let modalShowCancel = false;
|
||||
let modalTone: "default" | "danger" = "default";
|
||||
let modalAction: "logout-confirm" | "logout-info" | null = null;
|
||||
|
||||
function showModal(options: {
|
||||
action: "logout-confirm" | "logout-info";
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
showCancel?: boolean;
|
||||
tone?: "default" | "danger";
|
||||
}) {
|
||||
modalAction = options.action;
|
||||
modalTitle = options.title;
|
||||
modalMessage = options.message;
|
||||
modalConfirmText = options.confirmText ?? "OK";
|
||||
modalCancelText = options.cancelText ?? "Cancel";
|
||||
modalShowCancel = options.showCancel ?? false;
|
||||
modalTone = options.tone ?? "default";
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalOpen = false;
|
||||
modalAction = null;
|
||||
}
|
||||
|
||||
function handleModalConfirm() {
|
||||
if (modalAction === "logout-confirm") {
|
||||
showModal({
|
||||
action: "logout-info",
|
||||
title: "Logout Requested",
|
||||
message: "You have been logged out.",
|
||||
confirmText: "Close"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
closeModal();
|
||||
}
|
||||
|
||||
function handleSelect(id: string) {
|
||||
if (id === "account" || id === "settings") {
|
||||
goto(`/${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (id === "logout") {
|
||||
showModal({
|
||||
action: "logout-confirm",
|
||||
title: "Confirm Logout",
|
||||
message: "Are you sure you want to log out?",
|
||||
confirmText: "Log Out",
|
||||
cancelText: "Cancel",
|
||||
showCancel: true,
|
||||
tone: "danger"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedSection === id) {
|
||||
panelOpen = !panelOpen;
|
||||
return;
|
||||
}
|
||||
|
||||
selectedSection = id;
|
||||
panelOpen = true;
|
||||
}
|
||||
|
||||
function handleOpenDocument(doc: OpenDocument) {
|
||||
if (!(doc.id in openDocuments)) {
|
||||
openDocuments = { ...openDocuments, [doc.id]: doc.initialContent };
|
||||
}
|
||||
activeDocumentId = doc.id;
|
||||
activeDocumentLabel = doc.label;
|
||||
}
|
||||
|
||||
function handleDocumentContentChange(content: string) {
|
||||
openDocuments = { ...openDocuments, [activeDocumentId]: content };
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<img src="svelte.svg" alt="logo" />
|
||||
<h2>Journal</h2>
|
||||
</div>
|
||||
<ul class="sidebar-links">
|
||||
<h4>
|
||||
<span>Workspace</span>
|
||||
<div class="menu-separator"></div>
|
||||
</h4>
|
||||
<li>
|
||||
<a href="#">
|
||||
<span class="material-symbols-outlined"> menu_book </span>Entries</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#"><span class="material-symbols-outlined"> calendar_month </span>Calendar</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#"><span class="material-symbols-outlined"> auto_stories </span>Fragments</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#"><span class="material-symbols-outlined"> checklist </span>To-Do List</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#"><span class="material-symbols-outlined"> lists </span>List</a>
|
||||
</li>
|
||||
<h4>
|
||||
<span>Account</span>
|
||||
<div class="menu-separator"></div>
|
||||
</h4>
|
||||
<li>
|
||||
<a href="#"><span class="material-symbols-outlined"> account_circle </span>Account</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#"><span class="material-symbols-outlined"> settings </span>Settings</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#"><span class="material-symbols-outlined"> logout </span>Logout</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="user-account">
|
||||
<div class="user-profile">
|
||||
<img src="https://placehold.co/800x600/09090b/jpg" alt="alt" />
|
||||
<div class="user-detail">
|
||||
<h3>John Doe</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="app-shell" class:panel-closed={!panelOpen}>
|
||||
<Navbar activeSection={selectedSection} onSelect={handleSelect} />
|
||||
{#if panelOpen}
|
||||
<SidePanel
|
||||
activeSection={selectedSection}
|
||||
{activeDocumentId}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
/>
|
||||
{/if}
|
||||
<EditorPanel
|
||||
activeSection={selectedSection}
|
||||
openDocumentId={activeDocumentId}
|
||||
openDocumentName={activeDocumentLabel}
|
||||
openDocumentContent={openDocuments[activeDocumentId] ?? ""}
|
||||
onDocumentContentChange={handleDocumentContentChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AppModal
|
||||
open={modalOpen}
|
||||
title={modalTitle}
|
||||
message={modalMessage}
|
||||
confirmText={modalConfirmText}
|
||||
cancelText={modalCancelText}
|
||||
showCancel={modalShowCancel}
|
||||
tone={modalTone}
|
||||
onConfirm={handleModalConfirm}
|
||||
onCancel={closeModal}
|
||||
/>
|
||||
|
||||
<!-- <main class="container">
|
||||
<h1>Welcome to Tauri + Svelte</h1>
|
||||
|
||||
<form class="row" onsubmit={greet}>
|
||||
<input id="greet-input" placeholder="Enter a name..." bind:value={name} />
|
||||
<button type="submit">Greet</button>
|
||||
</form>
|
||||
<p>{greetMsg}</p>
|
||||
</main> -->
|
||||
|
||||
178
Journal.App/src/routes/account/+page.svelte
Normal file
178
Journal.App/src/routes/account/+page.svelte
Normal file
@ -0,0 +1,178 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import AppModal from "$lib/components/AppModal.svelte";
|
||||
import Navbar from "$lib/components/Navbar.svelte";
|
||||
|
||||
const activeSection = "account";
|
||||
|
||||
let modalOpen = false;
|
||||
let modalTitle = "";
|
||||
let modalMessage = "";
|
||||
let modalConfirmText = "OK";
|
||||
let modalCancelText = "Cancel";
|
||||
let modalShowCancel = false;
|
||||
let modalTone: "default" | "danger" = "default";
|
||||
let modalAction: "logout-confirm" | "logout-info" | null = null;
|
||||
|
||||
function showModal(options: {
|
||||
action: "logout-confirm" | "logout-info";
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
showCancel?: boolean;
|
||||
tone?: "default" | "danger";
|
||||
}) {
|
||||
modalAction = options.action;
|
||||
modalTitle = options.title;
|
||||
modalMessage = options.message;
|
||||
modalConfirmText = options.confirmText ?? "OK";
|
||||
modalCancelText = options.cancelText ?? "Cancel";
|
||||
modalShowCancel = options.showCancel ?? false;
|
||||
modalTone = options.tone ?? "default";
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalOpen = false;
|
||||
modalAction = null;
|
||||
}
|
||||
|
||||
function handleModalConfirm() {
|
||||
if (modalAction === "logout-confirm") {
|
||||
showModal({
|
||||
action: "logout-info",
|
||||
title: "Logout Requested",
|
||||
message: "You have been logged out.",
|
||||
confirmText: "Close"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
closeModal();
|
||||
}
|
||||
|
||||
function handleSelect(id: string) {
|
||||
if (id === "logout") {
|
||||
showModal({
|
||||
action: "logout-confirm",
|
||||
title: "Confirm Logout",
|
||||
message: "Are you sure you want to log out?",
|
||||
confirmText: "Log Out",
|
||||
cancelText: "Cancel",
|
||||
showCancel: true,
|
||||
tone: "danger"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (id === "account") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (id === "settings") {
|
||||
goto("/settings");
|
||||
return;
|
||||
}
|
||||
|
||||
goto("/");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="app-shell panel-closed">
|
||||
<Navbar {activeSection} onSelect={handleSelect} />
|
||||
|
||||
<main class="route-view">
|
||||
<header class="route-header">
|
||||
<h1>Account</h1>
|
||||
<p>Manage your profile and account preferences.</p>
|
||||
</header>
|
||||
|
||||
<section class="route-card">
|
||||
<label>
|
||||
Display Name
|
||||
<input type="text" value="John Doe" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Email
|
||||
<input type="email" value="john@example.com" />
|
||||
</label>
|
||||
|
||||
<button type="button">Save Changes</button>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<AppModal
|
||||
open={modalOpen}
|
||||
title={modalTitle}
|
||||
message={modalMessage}
|
||||
confirmText={modalConfirmText}
|
||||
cancelText={modalCancelText}
|
||||
showCancel={modalShowCancel}
|
||||
tone={modalTone}
|
||||
onConfirm={handleModalConfirm}
|
||||
onCancel={closeModal}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.route-view {
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background: linear-gradient(180deg, var(--surface-2) 0%, var(--bg-editor) 100%);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.route-header h1 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.route-header p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.route-card {
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-1);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.route-card label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.route-card input {
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 7px;
|
||||
background: var(--bg-app);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.route-card button {
|
||||
width: fit-content;
|
||||
padding: 7px 11px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface-3);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
183
Journal.App/src/routes/settings/+page.svelte
Normal file
183
Journal.App/src/routes/settings/+page.svelte
Normal file
@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import AppModal from "$lib/components/AppModal.svelte";
|
||||
import Navbar from "$lib/components/Navbar.svelte";
|
||||
|
||||
const activeSection = "settings";
|
||||
|
||||
let modalOpen = false;
|
||||
let modalTitle = "";
|
||||
let modalMessage = "";
|
||||
let modalConfirmText = "OK";
|
||||
let modalCancelText = "Cancel";
|
||||
let modalShowCancel = false;
|
||||
let modalTone: "default" | "danger" = "default";
|
||||
let modalAction: "logout-confirm" | "logout-info" | null = null;
|
||||
|
||||
function showModal(options: {
|
||||
action: "logout-confirm" | "logout-info";
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
showCancel?: boolean;
|
||||
tone?: "default" | "danger";
|
||||
}) {
|
||||
modalAction = options.action;
|
||||
modalTitle = options.title;
|
||||
modalMessage = options.message;
|
||||
modalConfirmText = options.confirmText ?? "OK";
|
||||
modalCancelText = options.cancelText ?? "Cancel";
|
||||
modalShowCancel = options.showCancel ?? false;
|
||||
modalTone = options.tone ?? "default";
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalOpen = false;
|
||||
modalAction = null;
|
||||
}
|
||||
|
||||
function handleModalConfirm() {
|
||||
if (modalAction === "logout-confirm") {
|
||||
showModal({
|
||||
action: "logout-info",
|
||||
title: "Logout Requested",
|
||||
message: "You have been logged out.",
|
||||
confirmText: "Close"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
closeModal();
|
||||
}
|
||||
|
||||
function handleSelect(id: string) {
|
||||
if (id === "logout") {
|
||||
showModal({
|
||||
action: "logout-confirm",
|
||||
title: "Confirm Logout",
|
||||
message: "Are you sure you want to log out?",
|
||||
confirmText: "Log Out",
|
||||
cancelText: "Cancel",
|
||||
showCancel: true,
|
||||
tone: "danger"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (id === "settings") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (id === "account") {
|
||||
goto("/account");
|
||||
return;
|
||||
}
|
||||
|
||||
goto("/");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="app-shell panel-closed">
|
||||
<Navbar {activeSection} onSelect={handleSelect} />
|
||||
|
||||
<main class="route-view">
|
||||
<header class="route-header">
|
||||
<h1>Settings</h1>
|
||||
<p>Configure app behavior and interface options.</p>
|
||||
</header>
|
||||
|
||||
<section class="route-card">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" checked />
|
||||
<span>Launch to last opened entry</span>
|
||||
</label>
|
||||
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" />
|
||||
<span>Enable compact editor mode</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Default startup view
|
||||
<select>
|
||||
<option>Entries</option>
|
||||
<option>Calendar</option>
|
||||
<option>Fragments</option>
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<AppModal
|
||||
open={modalOpen}
|
||||
title={modalTitle}
|
||||
message={modalMessage}
|
||||
confirmText={modalConfirmText}
|
||||
cancelText={modalCancelText}
|
||||
showCancel={modalShowCancel}
|
||||
tone={modalTone}
|
||||
onConfirm={handleModalConfirm}
|
||||
onCancel={closeModal}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.route-view {
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
background: linear-gradient(180deg, var(--surface-2) 0%, var(--bg-editor) 100%);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.route-header h1 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.route-header p {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.route-card {
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-1);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.route-card label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.route-card select {
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 7px;
|
||||
background: var(--bg-app);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@ -1,23 +1,42 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap");
|
||||
|
||||
:root {
|
||||
font-family: "Poppins", Inter, Avenir, Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-family: "Segoe UI Variable", "Segoe UI", Inter, Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
font-weight: 400;
|
||||
|
||||
/* Tailwind zinc dark palette */
|
||||
--zinc-950: #09090b;
|
||||
--zinc-900: #18181b;
|
||||
--zinc-800: #27272a;
|
||||
--zinc-700: #3f3f46;
|
||||
--zinc-600: #52525b;
|
||||
--zinc-300: #d4d4d8;
|
||||
--zinc-200: #e4e4e7;
|
||||
--zinc-50: #fafafa;
|
||||
--zinc-100: #f4f4f5;
|
||||
--zinc-200: #e4e4e7;
|
||||
--zinc-300: #d4d4d8;
|
||||
--zinc-400: #a1a1aa;
|
||||
--zinc-500: #71717a;
|
||||
--zinc-600: #52525b;
|
||||
--zinc-700: #3f3f46;
|
||||
--zinc-800: #27272a;
|
||||
--zinc-900: #18181b;
|
||||
--zinc-950: #09090b;
|
||||
|
||||
color: var(--zinc-100);
|
||||
background-color: var(--zinc-950);
|
||||
--bg-app: var(--zinc-950);
|
||||
--bg-navbar: var(--zinc-900);
|
||||
--bg-panel: var(--zinc-900);
|
||||
--bg-editor: var(--zinc-900);
|
||||
--bg-hover: var(--zinc-800);
|
||||
--bg-active: var(--zinc-700);
|
||||
|
||||
--surface-1: var(--zinc-900);
|
||||
--surface-2: var(--zinc-800);
|
||||
--surface-3: var(--zinc-700);
|
||||
|
||||
--border-soft: var(--zinc-700);
|
||||
--border-strong: var(--zinc-600);
|
||||
|
||||
--text-primary: var(--zinc-100);
|
||||
--text-muted: var(--zinc-300);
|
||||
--text-dim: var(--zinc-500);
|
||||
--accent: var(--zinc-200);
|
||||
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-app);
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
@ -32,160 +51,48 @@ body {
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(180deg, var(--zinc-950) 0%, #111113 100%);
|
||||
color: var(--zinc-100);
|
||||
background: radial-gradient(circle at 15% -10%, var(--zinc-800) 0%, var(--bg-app) 42%);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: "Poppins", sans-serif;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 85px;
|
||||
display: flex;
|
||||
overflow-x: hidden;
|
||||
flex-direction: column;
|
||||
background: var(--zinc-900);
|
||||
border-right: 1px solid var(--zinc-800);
|
||||
padding: 25px 20px;
|
||||
transition: all 0.4s ease;
|
||||
button,
|
||||
input {
|
||||
border: none;
|
||||
outline: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.sidebar:hover {
|
||||
width: 260px;
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 72px 300px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar .sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.app-shell.panel-closed {
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar .sidebar-header img {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 64px minmax(0, 1fr);
|
||||
grid-template-rows: 280px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sidebar .sidebar-header h2 {
|
||||
color: var(--zinc-100);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
margin-left: 23px;
|
||||
}
|
||||
.app-shell:not(.panel-closed) > .side-panel {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.sidebar-links h4 {
|
||||
color: var(--zinc-300);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
margin: 10px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-links h4 span {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sidebar:hover .sidebar-links h4 span {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-links .menu-separator {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
transform: scaleX(1);
|
||||
transform: translateY(-50%);
|
||||
background: var(--zinc-700);
|
||||
transform-origin: right;
|
||||
transition-delay: 0.2s;
|
||||
}
|
||||
|
||||
.sidebar:hover .sidebar-links .menu-separator {
|
||||
transition-delay: 0s;
|
||||
transform: scaleX(0);
|
||||
}
|
||||
|
||||
.sidebar-links {
|
||||
list-style: none;
|
||||
margin-top: 20px;
|
||||
height: 80%;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.sidebar-links::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sidebar-links li a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0 20px;
|
||||
color: var(--zinc-200);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
padding: 15px 10px;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
transition: 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar-links li a:hover {
|
||||
color: var(--zinc-100);
|
||||
background: var(--zinc-800);
|
||||
}
|
||||
|
||||
.user-account {
|
||||
margin-top: auto;
|
||||
padding: 12px 10px;
|
||||
margin-left: -10px;
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--zinc-200);
|
||||
}
|
||||
|
||||
.user-profile img {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--zinc-700);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-profile h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-profile span {
|
||||
font-size: 0.775rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-detail {
|
||||
margin-left: 23px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar:hover .user-account {
|
||||
background: var(--zinc-800);
|
||||
border-radius: 8px;
|
||||
.app-shell:not(.panel-closed) > .editor-panel {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@ -115,7 +115,6 @@ The `backend/` directory contains a .NET 10 implementation that provides the sam
|
||||
### Projects
|
||||
|
||||
- **Journal.Core** — shared library: domain models, services, repositories, DTOs
|
||||
- **Journal.Api** — minimal ASP.NET Core web API (`/api/command` POST endpoint)
|
||||
- **Journal.Sidecar** — console app (stdin/stdout JSON protocol or CLI with `vault` and `search` subcommands)
|
||||
- **Journal.SmokeTests** — 70+ integration tests (no test framework dependency)
|
||||
|
||||
|
||||
@ -1,84 +0,0 @@
|
||||
# Backend Refactoring Summary
|
||||
|
||||
## Problem
|
||||
`Entry.cs` was a 550-line god class that contained command dispatching, business logic, HTML sanitization, logging infrastructure, and 13 private payload record types. The two Python sidecar services (`PythonSidecarAiService` and `PythonSidecarSpeechService`) duplicated ~80 lines of identical process/JSON plumbing. Payload DTOs were hidden as private records inside `Entry.cs` instead of being in the `Dtos/` folder.
|
||||
|
||||
## What Changed
|
||||
|
||||
### 1. Slimmed `Entry.cs` to a thin dispatcher (~330 lines)
|
||||
Removed all business logic, HTML processing, logging implementation, and private record types. `Entry` now only parses the incoming JSON command, routes to the correct service, and returns the `{ok, data}` / `{ok: false, error}` envelope.
|
||||
|
||||
### 2. Extracted `HtmlSanitizer` (new file)
|
||||
`StripRichHtml` and `LooksLikeRichHtml` moved from `Entry.cs` to `Services/Entries/HtmlSanitizer.cs` as a static utility class. Refactored to use `[GeneratedRegex]` attributes for compile-time regex generation, improving performance by eliminating runtime regex compilation overhead.
|
||||
|
||||
### 3. Extracted `CommandLogger` (new file)
|
||||
`LogStart`, `LogSuccess`, `LogFailure`, `EmitLog`, `ShouldLog`, and `LogLevelRank` moved from `Entry.cs` to `Services/CommandLogger.cs`. Entry now receives this as a dependency.
|
||||
|
||||
### 4. Extracted `IEntryFileService` + `EntryFileService` (new files)
|
||||
`SaveEntry`, `LoadEntry`, `ListEntries`, and `ResolveTargetPath` moved out of `Entry.cs` into a proper service with an interface. This follows the same pattern as `IFragmentService` / `FragmentService`.
|
||||
|
||||
### 5. Added `IEntryFileRepository` + `DiskEntryFileRepository` (new files)
|
||||
`EntryFileService` now delegates all filesystem I/O (read, write, append, list, exists) to an `IEntryFileRepository`, keeping only business logic (HTML sanitization, parsing, merging) in the service. This mirrors the Fragment module's repository pattern (`IFragmentRepository` → `FragmentService`). An in-memory implementation can be swapped in for testing.
|
||||
|
||||
### 6. Extracted `PythonSidecarClient` (new file)
|
||||
The duplicated `SendAsync`, `LastJsonLine`, and `TryKill` methods were extracted from both `PythonSidecarAiService` and `PythonSidecarSpeechService` into a shared `Services/PythonSidecarClient.cs`. Both services now delegate to it.
|
||||
|
||||
### 7. Moved payload records to `Dtos/CommandDtos.cs` (new file)
|
||||
The 16 private payload/result records that were inside `Entry.cs` are now in `Dtos/CommandDtos.cs`. Records used through public interfaces (`EntrySavePayload`, `EntryListItem`, `EntryLoadResult`, `EntrySaveResult`) are public; the rest remain internal.
|
||||
|
||||
### 8. Moved database result records to `Dtos/DatabaseDtos.cs` (new file)
|
||||
`JournalDatabaseStatus` and `JournalDatabaseHydrationResult` moved from `IJournalDatabaseService.cs` to `Dtos/DatabaseDtos.cs` for consistency with the other DTO files.
|
||||
|
||||
### 9. Moved fragment storage to encrypted SQLCipher database
|
||||
Standalone fragments were previously stored in an unencrypted JSON file (`fragments.json`), then briefly in an unencrypted SQLite database. For medical/sensitive use cases, fragments are now stored in the **existing encrypted SQLCipher database** (`journal_cache.db`) alongside entries, sections, and tags.
|
||||
|
||||
- Updated `fragments` table schema: `entry_id` is now nullable, added `guid TEXT UNIQUE` column. Standalone fragments use `guid` + `entry_id IS NULL`; entry-linked fragments use `entry_id` + `guid NULL`.
|
||||
- `SqliteFragmentRepository` now depends on `IDatabaseSessionService` instead of managing its own database connection.
|
||||
- Tags use the shared normalized `tags` + `fragment_tags` tables (join via integer IDs).
|
||||
- Fragment CRUD requires the database to be unlocked first (via `vault.load_all` or `db.hydrate_workspace`).
|
||||
|
||||
### 10. Added `IDatabaseSessionService` + `DatabaseSessionService` (new files)
|
||||
A singleton that stores the encryption password in memory after authentication. When `vault.load_all` or `db.hydrate_workspace` is called, the session stores the password and lazily opens/caches an encrypted SQLCipher connection. All encrypted database consumers (currently `SqliteFragmentRepository`) use this shared session.
|
||||
|
||||
### 11. Organized Services directory into domain modules
|
||||
The flat `Services/` directory (28 files) was reorganized into 9 subdirectories with dedicated namespaces:
|
||||
|
||||
- `Services/Ai/` — `IAiService`, `DisabledAiService`, `PythonSidecarAiService`
|
||||
- `Services/Config/` — `IJournalConfigService`, `JournalConfigService`
|
||||
- `Services/Database/` — `IJournalDatabaseService`, `JournalDatabaseService`, `IDatabaseSessionService`, `DatabaseSessionService`
|
||||
- `Services/Entries/` — `IEntryFileService`, `EntryFileService`, `IEntrySearchService`, `EntrySearchService`, `JournalParser`, `HtmlSanitizer`
|
||||
- `Services/Fragments/` — `IFragmentService`, `FragmentService`
|
||||
- `Services/Logging/` — `CommandLogger`, `LogRedactor`
|
||||
- `Services/Sidecar/` — `PythonSidecarClient`, `SidecarCli`
|
||||
- `Services/Speech/` — `ISpeechBridgeService`, `DisabledSpeechBridgeService`, `PythonSidecarSpeechService`
|
||||
- `Services/Vault/` — `IVaultCryptoService`, `VaultCryptoService`, `IVaultStorageService`, `VaultStorageService`
|
||||
|
||||
Each subdirectory has its own namespace (e.g. `Journal.Core.Services.Ai`). All consumer files updated with explicit using statements.
|
||||
|
||||
## Files Created
|
||||
- `Journal.Core/Services/Entries/HtmlSanitizer.cs`
|
||||
- `Journal.Core/Services/Logging/CommandLogger.cs`
|
||||
- `Journal.Core/Services/Entries/IEntryFileService.cs`
|
||||
- `Journal.Core/Services/Entries/EntryFileService.cs`
|
||||
- `Journal.Core/Services/Sidecar/PythonSidecarClient.cs`
|
||||
- `Journal.Core/Repositories/IEntryFileRepository.cs`
|
||||
- `Journal.Core/Repositories/DiskEntryFileRepository.cs`
|
||||
- `Journal.Core/Repositories/SqliteFragmentRepository.cs`
|
||||
- `Journal.Core/Dtos/CommandDtos.cs`
|
||||
- `Journal.Core/Dtos/DatabaseDtos.cs`
|
||||
- `Journal.Core/Services/Database/IDatabaseSessionService.cs`
|
||||
- `Journal.Core/Services/Database/DatabaseSessionService.cs`
|
||||
|
||||
## Files Modified
|
||||
- `Journal.Core/Entry.cs` — slimmed to thin dispatcher, wired `IDatabaseSessionService`
|
||||
- `Journal.Core/Services/Ai/PythonSidecarAiService.cs` — delegates to PythonSidecarClient
|
||||
- `Journal.Core/Services/Speech/PythonSidecarSpeechService.cs` — delegates to PythonSidecarClient
|
||||
- `Journal.Core/Services/Database/IJournalDatabaseService.cs` — result records moved to Dtos
|
||||
- `Journal.Core/Services/Database/JournalDatabaseService.cs` — schema updated (nullable entry_id, guid column)
|
||||
- `Journal.Core/ServiceCollectionExtensions.cs` — registers all new services, updated namespaces
|
||||
- `Journal.SmokeTests/Program.cs` — updated with new dependencies and encrypted DB persistence test
|
||||
- `Journal.Sidecar/App.cs` — updated namespace imports
|
||||
|
||||
## Verification
|
||||
- All 4 projects build successfully
|
||||
- 65/70 smoke tests pass (5 Python sidecar tests fail only when Python is not installed on the machine, which is pre-existing)
|
||||
190
docs/frontend-csharp-backend-wiring.md
Normal file
190
docs/frontend-csharp-backend-wiring.md
Normal file
@ -0,0 +1,190 @@
|
||||
# Wiring Frontend to the C# Backend
|
||||
|
||||
This document explains how to connect the `Journal.App` frontend to the C# backend in this repository.
|
||||
|
||||
## Current Backend Reality
|
||||
|
||||
In this repo today, the C# backend projects in `Journal.slnx` are:
|
||||
|
||||
- `Journal.Core`
|
||||
- `Journal.Sidecar`
|
||||
- `Journal.SmokeTests`
|
||||
|
||||
There is currently **no** `Journal.Api` project in the solution file, so the primary integration path is:
|
||||
|
||||
- Frontend (Svelte/Tauri) -> Tauri bridge -> `Journal.Sidecar` (stdin/stdout JSON protocol)
|
||||
|
||||
## Command Protocol (C#)
|
||||
|
||||
`Journal.Core.Entry.HandleCommandAsync` accepts a JSON command envelope and returns:
|
||||
|
||||
- success: `{ "ok": true, "data": ... }`
|
||||
- failure: `{ "ok": false, "error": "..." }`
|
||||
|
||||
Command model (`Journal.Core/Models/Command.cs`):
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "entries.list",
|
||||
"correlationId": "optional-string",
|
||||
"id": "optional",
|
||||
"type": "optional",
|
||||
"tag": "optional",
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
Useful actions for frontend wiring:
|
||||
|
||||
- `entries.list`
|
||||
- `entries.load`
|
||||
- `entries.save`
|
||||
- `search.entries`
|
||||
- `vault.load_all`
|
||||
- `vault.save_current_month`
|
||||
- `db.status`
|
||||
- `db.hydrate_workspace`
|
||||
|
||||
## Recommended Integration (Sidecar Bridge)
|
||||
|
||||
Use a small frontend client that sends commands through one bridge function. The bridge can be backed by:
|
||||
|
||||
- a Tauri command that talks to a managed sidecar process, or
|
||||
- a local HTTP adapter (if you add one).
|
||||
|
||||
### 1. Define shared frontend command/response types
|
||||
|
||||
Create `Journal.App/src/lib/backend/types.ts`:
|
||||
|
||||
```ts
|
||||
export type BackendCommand = {
|
||||
action: string;
|
||||
correlationId?: string;
|
||||
id?: string;
|
||||
type?: string;
|
||||
tag?: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
export type BackendOk<T> = { ok: true; data: T };
|
||||
export type BackendErr = { ok: false; error: string };
|
||||
export type BackendResponse<T> = BackendOk<T> | BackendErr;
|
||||
```
|
||||
|
||||
### 2. Create one backend client entrypoint
|
||||
|
||||
Create `Journal.App/src/lib/backend/client.ts`:
|
||||
|
||||
```ts
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { BackendCommand, BackendResponse } from "./types";
|
||||
|
||||
export async function sendCommand<T>(command: BackendCommand): Promise<T> {
|
||||
const response = await invoke<BackendResponse<T>>("sidecar_command", { command });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error || "Backend command failed");
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
```
|
||||
|
||||
This keeps all UI code backend-agnostic.
|
||||
|
||||
### 3. Build domain helpers (entries example)
|
||||
|
||||
Create `Journal.App/src/lib/backend/entries.ts`:
|
||||
|
||||
```ts
|
||||
import { sendCommand } from "./client";
|
||||
|
||||
export async function listEntries(dataDirectory?: string) {
|
||||
return sendCommand<string[]>({
|
||||
action: "entries.list",
|
||||
payload: { dataDirectory }
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadEntry(filePath: string) {
|
||||
return sendCommand<{ filePath: string; content: string; section?: string }>({
|
||||
action: "entries.load",
|
||||
payload: { filePath }
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveEntry(args: {
|
||||
filePath?: string;
|
||||
content: string;
|
||||
title?: string;
|
||||
section?: string;
|
||||
date?: string;
|
||||
}) {
|
||||
return sendCommand<{ filePath: string }>({
|
||||
action: "entries.save",
|
||||
payload: args
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Use client in UI state
|
||||
|
||||
In page/component code:
|
||||
|
||||
- on panel item click: call `loadEntry(filePath)`
|
||||
- on editor save button: call `saveEntry({ filePath, content })`
|
||||
- on app init: call `listEntries()` to populate list
|
||||
|
||||
## Tauri Bridge Notes
|
||||
|
||||
Your frontend should not spawn/process-manage the sidecar directly. Keep that in the Tauri layer.
|
||||
|
||||
Bridge responsibilities:
|
||||
|
||||
- start and keep one sidecar process alive
|
||||
- write command JSON lines to sidecar stdin
|
||||
- read stdout lines and map responses by `correlationId`
|
||||
- return parsed response to frontend
|
||||
- restart sidecar if it crashes
|
||||
|
||||
If you have not implemented this yet, create one Tauri command such as:
|
||||
|
||||
- `sidecar_command(command)`
|
||||
|
||||
and route all frontend calls through it.
|
||||
|
||||
## Vault/Auth Flow
|
||||
|
||||
Recommended startup sequence:
|
||||
|
||||
1. Prompt for vault password in UI.
|
||||
2. Call `vault.load_all` (or `db.hydrate_workspace`) once.
|
||||
3. Backend stores session password (`DatabaseSessionService`) for subsequent commands.
|
||||
4. Continue with `entries.list`, `entries.load`, etc.
|
||||
|
||||
Do not store raw vault password in long-lived frontend state.
|
||||
|
||||
## Error Handling Pattern
|
||||
|
||||
Always normalize backend errors in one place:
|
||||
|
||||
- backend client throws `Error(message)` when `ok: false`
|
||||
- UI catches and displays your custom modal
|
||||
- include `correlationId` on commands for tracing/logging
|
||||
|
||||
## Optional HTTP Path (If You Add Journal.Api)
|
||||
|
||||
If you later add `Journal.Api` with `POST /api/command`, keep the same command envelope and swap transport only:
|
||||
|
||||
- replace `invoke("sidecar_command", ...)` with `fetch("/api/command", ...)`
|
||||
- keep `sendCommand` interface unchanged
|
||||
|
||||
That lets UI code remain identical.
|
||||
|
||||
## Minimal Next Steps
|
||||
|
||||
1. Add `src/lib/backend/types.ts`, `client.ts`, `entries.ts`.
|
||||
2. Wire `EditorPanel` save button to `entries.save`.
|
||||
3. Wire `SidePanel` item load to `entries.load`.
|
||||
4. Add vault unlock modal + `vault.load_all` on startup.
|
||||
5. Keep all backend calls behind `sendCommand` only.
|
||||
Loading…
x
Reference in New Issue
Block a user