Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions src/lib/components/HistoryPanel.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
<script lang="ts">
import { Clock, RotateCcw, User } from "@lucide/svelte";
import type { LoroNoteManager } from "$lib/loro.ts";
import { onMount } from "svelte";

interface Props {
manager: LoroNoteManager | undefined;
isOpen: boolean;
onClose: () => void;
}

let { manager, isOpen, onClose }: Props = $props();

interface HistoryEntry {
version: number;
timestamp: Date;
preview: string;
}

let history = $state<HistoryEntry[]>([]);
let selectedVersion = $state<number | null>(null);
let unsubscribe: (() => void) | null = null;

// Load history from Loro document
function loadHistory() {
if (!manager) {
history = [];
return;
}

history = manager.getHistory();
}

// Subscribe to live updates
$effect(() => {
if (manager && isOpen) {
loadHistory();

// Subscribe to document changes for live updates
unsubscribe = manager.subscribeToHistory(() => {
loadHistory();
});
}

return () => {
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
};
});

function restoreVersion(version: number) {
// TODO: Implement version restoration using Loro's checkout functionality
console.log("Restoring version:", version);
}

function formatTime(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);

if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
return `${days}d ago`;
}
</script>

{#if isOpen}
<div
class="fixed inset-y-0 right-0 z-50 flex w-80 flex-col border-l border-base-content/10 bg-base-100 shadow-xl"
>
<!-- Header -->
<div
class="flex items-center justify-between border-b border-base-content/10 p-4"
>
<div class="flex items-center gap-2">
<Clock class="h-5 w-5 text-primary" />
<h2 class="text-lg font-semibold">Version History</h2>
</div>
<button
onclick={onClose}
class="btn btn-circle btn-ghost btn-sm"
aria-label="Close history"
>
</button>
</div>

<!-- History List -->
<div class="flex-1 overflow-y-auto p-4">
{#if history.length === 0}
<div class="flex h-full items-center justify-center text-center">
<div>
<Clock class="mx-auto mb-2 h-12 w-12 text-base-content/30" />
<p class="text-sm text-base-content/60">No history available</p>
</div>
</div>
{:else}
<div class="space-y-2">
{#each history as entry, i (entry.version)}
<button
onclick={() => (selectedVersion = entry.version)}
class={[
"w-full rounded-lg border p-3 text-left transition-all",
selectedVersion === entry.version
? "border-primary bg-primary/10"
: "border-base-content/10 hover:border-primary/50 hover:bg-base-200",
].join(" ")}
>
<!-- Version Header -->
<div class="mb-2 flex items-center justify-between">
<div class="flex items-center gap-2">
{#if entry.author}
<div
class="flex h-6 w-6 items-center justify-center rounded-full bg-primary text-xs font-medium text-primary-content"
>
{entry.author[0].toUpperCase()}
</div>
<span class="text-sm font-medium">{entry.author}</span>
{:else}
<User class="h-4 w-4 text-base-content/50" />
<span class="text-sm text-base-content/60">Unknown</span>
{/if}
</div>
<span class="text-xs text-base-content/50">
{formatTime(entry.timestamp)}
</span>
</div>

<!-- Preview -->
<p class="line-clamp-2 text-xs text-base-content/70">
{entry.preview || "Empty document"}
</p>

<!-- Version Number -->
<div class="mt-2 flex items-center justify-between">
<span class="font-mono text-xs text-base-content/50">
v{entry.version}
</span>
{#if i === 0}
<span
class="rounded-full bg-success/20 px-2 py-0.5 text-xs font-medium text-success"
>
Current
</span>
{/if}
</div>
</button>
{/each}
</div>
{/if}
</div>

<!-- Actions -->
{#if selectedVersion !== null && selectedVersion !== history[0]?.version}
<div class="border-t border-base-content/10 p-4">
<button
onclick={() => restoreVersion(selectedVersion)}
class="btn w-full btn-primary"
>
<RotateCcw class="h-4 w-4" />
Restore This Version
</button>
</div>
{/if}
</div>

<!-- Backdrop -->
<button
onclick={onClose}
class="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm"
aria-label="Close history panel"
></button>
{/if}
180 changes: 107 additions & 73 deletions src/lib/components/Sidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
Plus,
Trash2,
Pencil,
ChevronLeft,
ChevronRight,
PanelLeftClose,
LogOut,
} from "@lucide/svelte";
import type { User } from "$lib/schema.ts";
import ProfilePicture from "./ProfilePicture.svelte";
Expand Down Expand Up @@ -39,9 +43,11 @@
interface Props {
user: User | undefined;
notesList: NoteOrFolder[];
isCollapsed: boolean;
toggleSidebar: () => void;
}

let { user, notesList }: Props = $props();
let { user, notesList, isCollapsed, toggleSidebar }: Props = $props();
let expandedFolders = new SvelteSet<string>();
let renamingId = $state<string | null>(null);
let renameTitle = $state("");
Expand All @@ -50,11 +56,14 @@

let notesTree = $derived(buildNotesTree(notesList));

let rootContainer: HTMLElement;
let rootContainer = $state<HTMLElement>();
let isRootDropTarget = $state(false);

// Set up root drop target
onMount(() => {
// Set up root drop target
$effect(() => {
if (!rootContainer) return;

const cleanup = dropTargetForElements({
element: rootContainer,
onDragEnter: () => {
Expand Down Expand Up @@ -205,84 +214,109 @@
<svelte:window onclick={onWindowClick} />

<div
class="sidebar flex h-full w-64 flex-col border-r border-base-content/10 [view-transition-name:sidebar]"
class="sidebar flex h-full flex-col border-r border-base-content/10 transition-all duration-300 [view-transition-name:sidebar]"
style="width: {isCollapsed ? '0' : '16rem'}"
>
<!-- User Header -->
<div
class="flex items-center justify-between border-b border-base-content/10 p-4"
>
<div class="flex items-center gap-2">
<ProfilePicture name={user?.username ?? "A"} />
<span class="max-w-28 truncate text-sm font-medium"
>{user?.username ?? "Anonymous"}</span
>
</div>
<form {...logout}>
{#if !isCollapsed}
<!-- User Header -->
<div
class="flex items-center justify-between border-b border-base-content/10 p-4"
>
<div class="dropdown dropdown-bottom">
<div
tabindex="0"
role="button"
class="btn flex items-center gap-2 px-1 font-normal btn-ghost btn-sm"
>
<ProfilePicture name={user?.username ?? "A"} />
<span class="max-w-[120px] truncate text-sm font-medium">
{user?.username ?? "Anonymous"}
</span>
</div>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
Comment thread
lishaduck marked this conversation as resolved.
<ul
tabindex="0"
class="dropdown-content menu z-[1] w-52 rounded-box bg-base-100 p-2 shadow"
>
<li>
<form {...logout} class="w-full">
<button type="submit" class="flex w-full items-center gap-2">
<LogOut size={16} />
Log out
</button>
</form>
</li>
</ul>
</div>

<button
type="submit"
class="text-xs text-base-content/40 transition-colors hover:text-base-content/60"
onclick={toggleSidebar}
class="btn btn-square btn-ghost btn-sm"
title="Collapse sidebar (Ctrl+B)"
>
Log out
<PanelLeftClose size={20} />
</button>
</form>
</div>
</div>

<!-- Actions -->
<div class="grid grid-cols-2 gap-2 p-3">
<button
onclick={async () => {
if (user === undefined) {
throw new Error("Cannot create note whilst logged out.");
}
<!-- Actions -->
<div class="grid grid-cols-2 gap-2 p-3">
<button
onclick={async () => {
if (user === undefined) {
throw new Error("Cannot create note whilst logged out.");
}

await handleCreateNote("Untitled Note", null, false, user.publicKey);
}}
class="btn"><FilePlus /> Note</button
>
<button
onclick={async () => {
if (user === undefined) {
throw new Error("Cannot create folder whilst logged out.");
}
await handleCreateNote("Untitled Note", null, false, user.publicKey);
}}
class="btn"><FilePlus /> Note</button
>
<button
onclick={async () => {
if (user === undefined) {
throw new Error("Cannot create folder whilst logged out.");
}

await handleCreateNote("New Folder", null, true, user.publicKey);
}}
class="btn"><FolderPlus /> Folder</button
>
</div>
await handleCreateNote("New Folder", null, true, user.publicKey);
}}
class="btn"><FolderPlus /> Folder</button
>
</div>

<!-- Note Tree -->
<div
bind:this={rootContainer}
class={[
"flex-1 space-y-1 overflow-y-auto px-2 py-2 transition-all",
isRootDropTarget && "bg-indigo-50 ring-2 ring-primary ring-inset",
]}
>
{#each notesTree as item, idx (item.id)}
<TreeItem
{item}
{expandedFolders}
{toggleFolder}
{handleContextMenu}
index={idx}
onReorder={handleRootReorder}
{notesList}
{notesTree}
/>
{/each}

<!-- Empty state -->
{#if notesTree.length === 0}
<div class="flex flex-col items-center justify-center py-12 text-center">
<File />
<p class="text-sm text-base-content">No notes yet</p>
<p class="mt-1 text-xs text-base-content/75">
Create your first note to get started
</p>
</div>
{/if}
</div>
<!-- Note Tree -->
<div
bind:this={rootContainer}
class={[
"flex-1 space-y-1 overflow-y-auto px-2 py-2 transition-all",
isRootDropTarget && "bg-indigo-50 ring-2 ring-primary ring-inset",
]}
>
{#each notesTree as item, idx (item.id)}
<TreeItem
{item}
{expandedFolders}
{toggleFolder}
{handleContextMenu}
index={idx}
onReorder={handleRootReorder}
{notesList}
{notesTree}
/>
{/each}

<!-- Empty state -->
{#if notesTree.length === 0}
<div
class="flex flex-col items-center justify-center py-12 text-center"
>
<File />
<p class="text-sm text-base-content">No notes yet</p>
<p class="mt-1 text-xs text-base-content/75">
Create your first note to get started
</p>
</div>
{/if}
</div>
{/if}
</div>

<!-- Context Menu -->
Expand Down
Loading