qnote/web/src/lib/components/NoteView.svelte

357 lines
8.8 KiB
Svelte

<script lang="ts">
import { onDestroy, onMount } from "svelte"
import { goto } from "$app/navigation"
import ThemeToggle from "./ThemeToggle.svelte"
import Sidebar from "./Sidebar.svelte"
import NoteEditor from "./NoteEditor.svelte"
import SettingsModal from "./SettingsModal.svelte"
import {
apiClient,
currentUser,
currentFullNote,
availableNotes,
versionHistory,
cError,
type FullNote,
cSuccess
} from "$lib/client"
import ToggleSidebar from "$lib/icons/ToggleSidebar.svelte"
import VersionArrow from "$lib/icons/VersionArrow.svelte"
import Close from "$lib/icons/Close.svelte"
import { ERR_NOTIFICATION_DUR, SUC_NOTIFICATION_DUR } from "$lib/const"
// State
let isComponentReady = false
let sidebarOpen = window.innerWidth > 768
let showSettings = false
let showVersionsDropdown = false
let isEditing = false
let errorTimeout: ReturnType<typeof setTimeout> | null = null
let successTimeout: ReturnType<typeof setTimeout> | null = null
onMount(async (): Promise<any> => {
// The following fetch attempts to refresh any expired tokens automatically
await apiClient.getCurrentUser()
// If still no current user after the fetch attempt, redirect to login
if (!$currentUser) {
console.log("[VIEW] No user data found, routing to login page")
goto("/login")
return
}
await loadNotes()
// Default to sidebar closed on mobile
const handleResize = () => {
if (window.innerWidth < 768 && sidebarOpen) {
sidebarOpen = false
}
}
handleResize()
// Keep listening to browser's resize events
window.addEventListener("resize", handleResize)
isComponentReady = true
return () => {
window.removeEventListener("resize", handleResize)
}
})
const loadNotes = async () => {
const notes = await apiClient.listNotes()
if (notes) {
availableNotes.set(notes)
}
}
const toggleVersionDropdown = () => {
showVersionsDropdown = !showVersionsDropdown
}
const toggleSidebar = () => {
sidebarOpen = !sidebarOpen
}
const closeSidebar = () => {
sidebarOpen = false
}
const toggleSettings = () => {
showSettings = !showSettings
}
const createNewNote = async () => {
const newNote = await apiClient.createNote()
if (newNote) {
// Refresh notes list
await loadNotes()
// Get the full note details of the newly created note
// (we need to find the ID from the updated `availableNotes`)
if ($availableNotes && $availableNotes.length > 0) {
const latestNote = $availableNotes[0] // Assuming the latest note is first
await selectNote(latestNote.id, true)
}
isEditing = true // Open brand new notes in edit mode by default
}
}
const isLatestVersion = (note: FullNote | null) => {
if (!note) {
return true
}
return note.isActiveVersion
}
const selectNote = async (noteID: string, fetchRemote: boolean) => {
const note = await apiClient.getActiveFullNote(noteID, fetchRemote)
if (note) {
currentFullNote.set(note)
isEditing = false
}
const history = await apiClient.getNoteHistory(noteID, fetchRemote)
if (history) {
versionHistory.set(history)
}
// MOBILE: Close sidebar when selecting a note from the list
if (window.innerWidth < 768) {
closeSidebar()
}
}
const selectVersion = async (versionID: string) => {
if ($currentFullNote) {
const note = await apiClient.getFullVersion($currentFullNote.id, versionID)
if (note) {
currentFullNote.set(note)
showVersionsDropdown = false
}
}
}
const toggleEditMode = () => {
if (isLatestVersion($currentFullNote)) {
isEditing = !isEditing
} else {
cError.set("Editing historical versions is prohibited.")
}
}
const saveNote = async (title: string, content: string) => {
if ($currentFullNote) {
await apiClient.createVersion($currentFullNote.id, title, content)
// Refresh the current note to get the latest version (+ assure that client and server are synced)
await selectNote($currentFullNote.id, true)
// Refresh the notes list to update any changes (ordering based on `updatedAt` field)
await loadNotes()
isEditing = false
}
}
const deleteNote = async (noteID: string) => {
await apiClient.deleteNote(noteID)
// If we're deleting the currently active note, clear the current note
if ($currentFullNote && $currentFullNote.id === noteID) {
currentFullNote.set(null)
}
// Refresh the notes list due to updates being pushed to server
await loadNotes()
cSuccess.set("Note deleted successfully.")
}
const logout = async () => {
await apiClient.logout()
}
const errorUnsubscribe = cError.subscribe((value) => {
// Clear any existing timeout to avoid multiple timeouts
if (errorTimeout) {
clearTimeout(errorTimeout)
errorTimeout = null
}
if (value) {
errorTimeout = setTimeout(() => {
cError.set(null)
}, ERR_NOTIFICATION_DUR)
}
})
const successUnsubscribe = cSuccess.subscribe((value) => {
// Clear any existing timeout to avoid multiple timeouts
if (successTimeout) {
clearTimeout(successTimeout)
successTimeout = null
}
if (value) {
successTimeout = setTimeout(() => {
cSuccess.set(null)
}, SUC_NOTIFICATION_DUR)
}
})
onDestroy(() => {
errorUnsubscribe()
successUnsubscribe()
// Clear any pending timeouts
if (errorTimeout) {
clearTimeout(errorTimeout)
}
if (successTimeout) {
clearTimeout(successTimeout)
}
})
</script>
{#if isComponentReady}
<div class="main-layout-container">
<!-- Error notification -->
{#if $cError}
<div class="main-info-popup">
<div class="error">
{$cError}
<button class="main-info-popup-button" on:click={() => cError.set(null)}>
<Close />
</button>
</div>
</div>
{/if}
<!-- Success notification -->
{#if $cSuccess}
<div class="main-info-popup">
<div class="success">
{$cSuccess}
<button class="main-info-popup-button" on:click={() => cSuccess.set(null)}>
<Close />
</button>
</div>
</div>
{/if}
<!-- Sidebar (2-way binding prop allows changing the same var. in either component) -->
<Sidebar
bind:sidebarOpen
notes={$availableNotes || []}
currentNote={$currentFullNote}
{toggleSettings}
{logout}
{createNewNote}
{selectNote}
{deleteNote}
on:close={closeSidebar}
/>
<!-- Main content area -->
<div
class="content-wrapper transition-all duration-300"
class:md:-ml-64={!sidebarOpen}
class:md:ml-0={sidebarOpen}
>
<!-- Top navbar -->
<header class="main-header">
<button
on:click={toggleSidebar}
class="btn-secondary rounded-full p-2"
aria-label="Toggle sidebar"
>
<ToggleSidebar />
</button>
<div class="flex items-center space-x-4 pl-2">
{#if $currentFullNote}
<!-- Note edit/preview button (only shown for latest version) -->
{#if isLatestVersion($currentFullNote)}
<button on:click={toggleEditMode} class="btn-primary rounded-full">
{isEditing ? "Preview" : "Edit"}
</button>
{:else}
<button disabled class="btn-primary cursor-not-allowed rounded-full opacity-40">
Edit
</button>
{/if}
<!-- Version history dropdown menu -->
<div class="relative">
<button
on:click={toggleVersionDropdown}
class="btn-secondary flex items-center rounded-full"
>
<span>Versions</span>
<VersionArrow />
</button>
{#if showVersionsDropdown && $versionHistory && $versionHistory.length > 0}
<div class="versions-dropdown">
{#each $versionHistory as version}
<button
on:click={() => selectVersion(version.versionID)}
class="versions-dropdown-item {$currentFullNote.versionNumber ===
version.versionNumber
? 'versions-dropdown-item-active'
: ''}"
>
<p class="versions-dropdown-item-text truncate font-bold">{version.title}</p>
<span class="versions-dropdown-item-text"
>{version.createdAt.toLocaleString()}</span
>
{#if version.isActive}<span
class="versions-dropdown-item-text ml-2 opacity-70">(active)</span
>{/if}
</button>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<div class="flex items-center space-x-4 pl-2">
<ThemeToggle />
</div>
</header>
<!-- Note content area with fixed width -->
<div class="note-content-fixed-width">
<main class="main-content">
{#if $currentFullNote}
<NoteEditor note={$currentFullNote} bind:isEditing {saveNote} />
{:else}
<div class="flex h-full flex-col items-center justify-center">
<p class="mb-4 text-lg">None selected</p>
<button on:click={createNewNote} class="btn-primary">Create note</button>
</div>
{/if}
</main>
</div>
</div>
<!-- Settings Modal -->
{#if showSettings}
<SettingsModal onClose={toggleSettings} />
{/if}
</div>
{/if}