357 lines
8.8 KiB
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}
|