Refactor journal UI and add markdown editor workflow

This commit is contained in:
Jacob Schmidt 2026-02-25 17:52:30 -06:00
parent 5167304cb4
commit e4c156eb58
12 changed files with 2126 additions and 304 deletions

View 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>

View 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>

View 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
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>

View 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>

View 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>

View File

@ -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> -->

View 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>

View 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>

View File

@ -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;
}
}

View File

@ -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)

View File

@ -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)

View 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.