Spaces:
Paused
Paused
| <script lang="ts"> | |
| import { toast } from 'svelte-sonner'; | |
| import fileSaver from 'file-saver'; | |
| const { saveAs } = fileSaver; | |
| import jsPDF from 'jspdf'; | |
| import html2canvas from 'html2canvas-pro'; | |
| import dayjs from '$lib/dayjs'; | |
| import duration from 'dayjs/plugin/duration'; | |
| import relativeTime from 'dayjs/plugin/relativeTime'; | |
| dayjs.extend(duration); | |
| dayjs.extend(relativeTime); | |
| async function loadLocale(locales) { | |
| for (const locale of locales) { | |
| try { | |
| dayjs.locale(locale); | |
| break; // Stop after successfully loading the first available locale | |
| } catch (error) { | |
| console.error(`Could not load locale '${locale}':`, error); | |
| } | |
| } | |
| } | |
| // Assuming $i18n.languages is an array of language codes | |
| $: loadLocale($i18n.languages); | |
| import { goto } from '$app/navigation'; | |
| import { onMount, getContext, onDestroy } from 'svelte'; | |
| import { WEBUI_NAME, config, prompts as _prompts, user } from '$lib/stores'; | |
| import { createNewNote, deleteNoteById, getNotes } from '$lib/apis/notes'; | |
| import { capitalizeFirstLetter } from '$lib/utils'; | |
| import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte'; | |
| import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; | |
| import Search from '../icons/Search.svelte'; | |
| import Plus from '../icons/Plus.svelte'; | |
| import ChevronRight from '../icons/ChevronRight.svelte'; | |
| import Spinner from '../common/Spinner.svelte'; | |
| import Tooltip from '../common/Tooltip.svelte'; | |
| import NoteMenu from './Notes/NoteMenu.svelte'; | |
| import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte'; | |
| import { marked } from 'marked'; | |
| const i18n = getContext('i18n'); | |
| let loaded = false; | |
| let importFiles = ''; | |
| let query = ''; | |
| let notes = []; | |
| let selectedNote = null; | |
| let showDeleteConfirm = false; | |
| const init = async () => { | |
| notes = await getNotes(localStorage.token); | |
| }; | |
| const createNoteHandler = async () => { | |
| const res = await createNewNote(localStorage.token, { | |
| title: $i18n.t('New Note'), | |
| data: { | |
| content: { | |
| json: null, | |
| html: '', | |
| md: '' | |
| } | |
| }, | |
| meta: null, | |
| access_control: null | |
| }).catch((error) => { | |
| toast.error(`${error}`); | |
| return null; | |
| }); | |
| if (res) { | |
| goto(`/notes/${res.id}`); | |
| } | |
| }; | |
| const downloadHandler = async (type) => { | |
| console.log('downloadHandler', type); | |
| console.log('selectedNote', selectedNote); | |
| if (type === 'md') { | |
| const blob = new Blob([selectedNote.data.content.md], { type: 'text/markdown' }); | |
| saveAs(blob, `${selectedNote.title}.md`); | |
| } else if (type === 'pdf') { | |
| await downloadPdf(selectedNote); | |
| } | |
| }; | |
| const downloadPdf = async (note) => { | |
| try { | |
| // Define a fixed virtual screen size | |
| const virtualWidth = 1024; // Fixed width (adjust as needed) | |
| const virtualHeight = 1400; // Fixed height (adjust as needed) | |
| // STEP 1. Get a DOM node to render | |
| const html = note.data?.content?.html ?? ''; | |
| let node; | |
| if (html instanceof HTMLElement) { | |
| node = html; | |
| } else { | |
| // If it's HTML string, render to a temporary hidden element | |
| node = document.createElement('div'); | |
| node.innerHTML = html; | |
| document.body.appendChild(node); | |
| } | |
| // Render to canvas with predefined width | |
| const canvas = await html2canvas(node, { | |
| useCORS: true, | |
| scale: 2, // Keep at 1x to avoid unexpected enlargements | |
| width: virtualWidth, // Set fixed virtual screen width | |
| windowWidth: virtualWidth, // Ensure consistent rendering | |
| windowHeight: virtualHeight | |
| }); | |
| // Remove hidden node if needed | |
| if (!(html instanceof HTMLElement)) { | |
| document.body.removeChild(node); | |
| } | |
| const imgData = canvas.toDataURL('image/png'); | |
| // A4 page settings | |
| const pdf = new jsPDF('p', 'mm', 'a4'); | |
| const imgWidth = 210; // A4 width in mm | |
| const pageHeight = 297; // A4 height in mm | |
| // Maintain aspect ratio | |
| const imgHeight = (canvas.height * imgWidth) / canvas.width; | |
| let heightLeft = imgHeight; | |
| let position = 0; | |
| pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight); | |
| heightLeft -= pageHeight; | |
| // Handle additional pages | |
| while (heightLeft > 0) { | |
| position -= pageHeight; | |
| pdf.addPage(); | |
| pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight); | |
| heightLeft -= pageHeight; | |
| } | |
| pdf.save(`${note.title}.pdf`); | |
| } catch (error) { | |
| console.error('Error generating PDF', error); | |
| toast.error(`${error}`); | |
| } | |
| }; | |
| const deleteNoteHandler = async (id) => { | |
| const res = await deleteNoteById(localStorage.token, id).catch((error) => { | |
| toast.error(`${error}`); | |
| return null; | |
| }); | |
| if (res) { | |
| init(); | |
| } | |
| }; | |
| const inputFilesHandler = async (inputFiles) => { | |
| // Check if all the file is a markdown file and extract name and content | |
| for (const file of inputFiles) { | |
| if (file.type !== 'text/markdown') { | |
| toast.error($i18n.t('Only markdown files are allowed')); | |
| return; | |
| } | |
| const reader = new FileReader(); | |
| reader.onload = async (event) => { | |
| const content = event.target.result; | |
| let name = file.name.replace(/\.md$/, ''); | |
| if (typeof content !== 'string') { | |
| toast.error($i18n.t('Invalid file content')); | |
| return; | |
| } | |
| // Create a new note with the content | |
| const res = await createNewNote(localStorage.token, { | |
| title: name, | |
| data: { | |
| content: { | |
| json: null, | |
| html: marked.parse(content ?? ''), | |
| md: content | |
| } | |
| }, | |
| meta: null, | |
| access_control: null | |
| }).catch((error) => { | |
| toast.error(`${error}`); | |
| return null; | |
| }); | |
| if (res) { | |
| init(); | |
| } | |
| }; | |
| reader.readAsText(file); | |
| } | |
| }; | |
| let dragged = false; | |
| const onDragOver = (e) => { | |
| e.preventDefault(); | |
| // Check if a file is being dragged. | |
| if (e.dataTransfer?.types?.includes('Files')) { | |
| dragged = true; | |
| } else { | |
| dragged = false; | |
| } | |
| }; | |
| const onDragLeave = () => { | |
| dragged = false; | |
| }; | |
| const onDrop = async (e) => { | |
| e.preventDefault(); | |
| console.log(e); | |
| if (e.dataTransfer?.files) { | |
| const inputFiles = Array.from(e.dataTransfer?.files); | |
| if (inputFiles && inputFiles.length > 0) { | |
| console.log(inputFiles); | |
| inputFilesHandler(inputFiles); | |
| } | |
| } | |
| dragged = false; | |
| }; | |
| onDestroy(() => { | |
| console.log('destroy'); | |
| const dropzoneElement = document.getElementById('notes-container'); | |
| if (dropzoneElement) { | |
| dropzoneElement?.removeEventListener('dragover', onDragOver); | |
| dropzoneElement?.removeEventListener('drop', onDrop); | |
| dropzoneElement?.removeEventListener('dragleave', onDragLeave); | |
| } | |
| }); | |
| onMount(async () => { | |
| await init(); | |
| loaded = true; | |
| const dropzoneElement = document.getElementById('notes-container'); | |
| dropzoneElement?.addEventListener('dragover', onDragOver); | |
| dropzoneElement?.addEventListener('drop', onDrop); | |
| dropzoneElement?.addEventListener('dragleave', onDragLeave); | |
| }); | |
| </script> | |
| <svelte:head> | |
| <title> | |
| {$i18n.t('Notes')} • {$WEBUI_NAME} | |
| </title> | |
| </svelte:head> | |
| <FilesOverlay show={dragged} /> | |
| <div id="notes-container" class="w-full min-h-full h-full"> | |
| {#if loaded} | |
| <DeleteConfirmDialog | |
| bind:show={showDeleteConfirm} | |
| title={$i18n.t('Delete note?')} | |
| on:confirm={() => { | |
| deleteNoteHandler(selectedNote.id); | |
| showDeleteConfirm = false; | |
| }} | |
| > | |
| <div class=" text-sm text-gray-500"> | |
| {$i18n.t('This will delete')} <span class=" font-semibold">{selectedNote.title}</span>. | |
| </div> | |
| </DeleteConfirmDialog> | |
| <div class="px-4.5 @container h-full pt-2"> | |
| {#if Object.keys(notes).length > 0} | |
| <div class="pb-10"> | |
| {#each Object.keys(notes) as timeRange} | |
| <div class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium pb-2.5"> | |
| {$i18n.t(timeRange)} | |
| </div> | |
| <div | |
| class="mb-5 gap-2.5 grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4" | |
| > | |
| {#each notes[timeRange] as note, idx (note.id)} | |
| <div | |
| class=" flex space-x-4 cursor-pointer w-full px-4.5 py-4 bg-gray-50 dark:bg-gray-850 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl transition" | |
| > | |
| <div class=" flex flex-1 space-x-4 cursor-pointer w-full"> | |
| <a | |
| href={`/notes/${note.id}`} | |
| class="w-full -translate-y-0.5 flex flex-col justify-between" | |
| > | |
| <div class="flex-1"> | |
| <div class=" flex items-center gap-2 self-center mb-1 justify-between"> | |
| <div class=" font-semibold line-clamp-1 capitalize">{note.title}</div> | |
| <div> | |
| <NoteMenu | |
| onDownload={(type) => { | |
| selectedNote = note; | |
| downloadHandler(type); | |
| }} | |
| onDelete={() => { | |
| selectedNote = note; | |
| showDeleteConfirm = true; | |
| }} | |
| > | |
| <button | |
| class="self-center w-fit text-sm p-1 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl" | |
| type="button" | |
| > | |
| <EllipsisHorizontal className="size-5" /> | |
| </button> | |
| </NoteMenu> | |
| </div> | |
| </div> | |
| <div | |
| class=" text-xs text-gray-500 dark:text-gray-500 mb-3 line-clamp-5 min-h-18" | |
| > | |
| {#if note.data?.content?.md} | |
| {note.data?.content?.md} | |
| {:else} | |
| {$i18n.t('No content')} | |
| {/if} | |
| </div> | |
| </div> | |
| <div class=" text-xs px-0.5 w-full flex justify-between items-center"> | |
| <div> | |
| {dayjs(note.updated_at / 1000000).fromNow()} | |
| </div> | |
| <Tooltip | |
| content={note?.user?.email ?? $i18n.t('Deleted User')} | |
| className="flex shrink-0" | |
| placement="top-start" | |
| > | |
| <div class="shrink-0 text-gray-500"> | |
| {$i18n.t('By {{name}}', { | |
| name: capitalizeFirstLetter( | |
| note?.user?.name ?? note?.user?.email ?? $i18n.t('Deleted User') | |
| ) | |
| })} | |
| </div> | |
| </Tooltip> | |
| </div> | |
| </a> | |
| </div> | |
| </div> | |
| {/each} | |
| </div> | |
| {/each} | |
| </div> | |
| {:else} | |
| <div class="w-full h-full flex flex-col items-center justify-center"> | |
| <div class="pb-20 text-center"> | |
| <div class=" text-xl font-medium text-gray-400 dark:text-gray-600"> | |
| {$i18n.t('No Notes')} | |
| </div> | |
| <div class="mt-1 text-sm text-gray-300 dark:text-gray-700"> | |
| {$i18n.t('Create your first note by clicking on the plus button below.')} | |
| </div> | |
| </div> | |
| </div> | |
| {/if} | |
| </div> | |
| <div class="absolute bottom-0 left-0 right-0 p-5 max-w-full flex justify-end"> | |
| <div class="flex gap-0.5 justify-end w-full"> | |
| <Tooltip content={$i18n.t('Create Note')}> | |
| <button | |
| class="cursor-pointer p-2.5 flex rounded-full border border-gray-50 bg-white dark:border-none dark:bg-gray-850 hover:bg-gray-50 dark:hover:bg-gray-800 transition shadow-xl" | |
| type="button" | |
| on:click={async () => { | |
| createNoteHandler(); | |
| }} | |
| > | |
| <Plus className="size-4.5" strokeWidth="2.5" /> | |
| </button> | |
| </Tooltip> | |
| <!-- <button | |
| class="cursor-pointer p-2.5 flex rounded-full hover:bg-gray-100 dark:hover:bg-gray-850 transition shadow-xl" | |
| > | |
| <SparklesSolid className="size-4" /> | |
| </button> --> | |
| </div> | |
| </div> | |
| <!-- {#if $user?.role === 'admin'} | |
| <div class=" flex justify-end w-full mb-3"> | |
| <div class="flex space-x-2"> | |
| <input | |
| id="notes-import-input" | |
| bind:files={importFiles} | |
| type="file" | |
| accept=".md" | |
| hidden | |
| on:change={() => { | |
| console.log(importFiles); | |
| const reader = new FileReader(); | |
| reader.onload = async (event) => { | |
| console.log(event.target.result); | |
| }; | |
| reader.readAsText(importFiles[0]); | |
| }} | |
| /> | |
| <button | |
| class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition" | |
| on:click={() => { | |
| const notesImportInputElement = document.getElementById('notes-import-input'); | |
| if (notesImportInputElement) { | |
| notesImportInputElement.click(); | |
| } | |
| }} | |
| > | |
| <div class=" self-center mr-2 font-medium line-clamp-1">{$i18n.t('Import Notes')}</div> | |
| <div class=" self-center"> | |
| <svg | |
| xmlns="http://www.w3.org/2000/svg" | |
| viewBox="0 0 16 16" | |
| fill="currentColor" | |
| class="w-4 h-4" | |
| > | |
| <path | |
| fill-rule="evenodd" | |
| d="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 9.5a.75.75 0 0 1-.75-.75V8.06l-.72.72a.75.75 0 0 1-1.06-1.06l2-2a.75.75 0 0 1 1.06 0l2 2a.75.75 0 1 1-1.06 1.06l-.72-.72v2.69a.75.75 0 0 1-.75.75Z" | |
| clip-rule="evenodd" | |
| /> | |
| </svg> | |
| </div> | |
| </button> | |
| </div> | |
| </div> | |
| {/if} --> | |
| {:else} | |
| <div class="w-full h-full flex justify-center items-center"> | |
| <Spinner /> | |
| </div> | |
| {/if} | |
| </div> | |