ai / frontend /src /lib /components /notes /Notes.svelte
fdrah
initial-commit
9e93b10
Raw
History Blame Contribute Delete
23.4 kB
<script lang="ts">
import { marked } from "marked";
import { toast } from "svelte-sonner";
import fileSaver from "file-saver";
const { saveAs } = fileSaver;
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);
}
}
}
import { onMount, getContext, onDestroy } from "svelte";
const i18n = getContext("i18n");
// Assuming $i18n.languages is an array of language codes
$: loadLocale($i18n.languages);
import { goto } from "$app/navigation";
import { REXPRO_NAME, config, user, pinnedNotes } from "$lib/stores";
import {
createNewNote,
deleteNoteById,
getNoteById,
getNoteList,
searchNotes,
toggleNotePinnedStatusById,
getPinnedNoteList,
} from "$lib/apis/notes";
import {
capitalizeFirstLetter,
copyToClipboard,
getTimeRange,
} from "$lib/utils";
import { downloadPdf, createNoteHandler } from "./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 XMark from "../icons/XMark.svelte";
import DropdownOptions from "../common/DropdownOptions.svelte";
import Loader from "../common/Loader.svelte";
let loaded = false;
let importFiles = "";
let selectedNote = null;
let showDeleteConfirm = false;
let notes = {};
let items = null;
let total = null;
let query = "";
let searchDebounceTimer: ReturnType<typeof setTimeout>;
let sortKey = null;
let displayOption = null;
let viewOption = null;
let permission = null;
let page = 1;
let itemsLoading = false;
let allItemsLoaded = false;
const downloadHandler = async (type) => {
// Fetch the full note since the list response may not contain full content
const note = await getNoteById(
localStorage.token,
selectedNote.id,
).catch((error) => {
toast.error(`${error}`);
return null;
});
if (!note) return;
if (type === "txt") {
const blob = new Blob([note.data.content.md], {
type: "text/plain",
});
saveAs(blob, `${note.title}.txt`);
} else if (type === "md") {
const blob = new Blob([note.data.content.md], {
type: "text/markdown",
});
saveAs(blob, `${note.title}.md`);
} else if (type === "pdf") {
try {
await downloadPdf(note);
} catch (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_grants: [],
}).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
init();
}
};
reader.readAsText(file);
}
};
const reset = () => {
page = 1;
items = null;
total = null;
allItemsLoaded = false;
itemsLoading = false;
notes = {};
};
const loadMoreItems = async () => {
if (allItemsLoaded) return;
page += 1;
await getItemsPage();
};
const init = async () => {
reset();
await getItemsPage();
};
$: if (query !== undefined) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
if (loaded) {
init();
}
}, 300);
}
$: if (
loaded &&
sortKey !== undefined &&
permission !== undefined &&
viewOption !== undefined
) {
init();
}
const getItemsPage = async () => {
itemsLoading = true;
if (viewOption === "created") {
permission = null;
}
const res = await searchNotes(
localStorage.token,
query,
viewOption,
permission,
sortKey,
page,
).catch(() => {
return [];
});
if (res) {
console.log(res);
total = res.total;
const pageItems = res.items;
if ((pageItems ?? []).length === 0) {
allItemsLoaded = true;
} else {
allItemsLoaded = false;
}
if (items) {
const existingIds = new Set(items.map((item) => item.id));
const newItems = pageItems.filter(
(item) => !existingIds.has(item.id),
);
items = [...items, ...newItems];
} else {
items = pageItems;
}
}
itemsLoading = false;
return res;
};
const groupNotes = (res) => {
if (!Array.isArray(res)) {
return []; // Return empty array for invalid input
}
// Build the grouped object while tracking order
const grouped: Record<string, any[]> = {};
const orderedKeys: string[] = [];
for (const note of res) {
const timeRange = getTimeRange(note.updated_at / 1000000000);
if (!grouped[timeRange]) {
grouped[timeRange] = [];
orderedKeys.push(timeRange);
}
grouped[timeRange].push({
...note,
timeRange,
});
}
// Return as array of [timeRange, notes] to preserve insertion order
return orderedKeys.map((key) => [key, grouped[key]] as [string, any[]]);
};
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;
};
onMount(() => {
viewOption = localStorage?.noteViewOption ?? null;
displayOption = localStorage?.noteDisplayOption ?? null;
loaded = true;
const dropzoneElement = document.getElementById("notes-container");
dropzoneElement?.addEventListener("dragover", onDragOver);
dropzoneElement?.addEventListener("drop", onDrop);
dropzoneElement?.addEventListener("dragleave", onDragLeave);
return () => {
clearTimeout(searchDebounceTimer);
if (dropzoneElement) {
dropzoneElement?.removeEventListener("dragover", onDragOver);
dropzoneElement?.removeEventListener("drop", onDrop);
dropzoneElement?.removeEventListener("dragleave", onDragLeave);
}
};
});
</script>
<svelte:head>
<title>
{$i18n.t("Notes")}{$REXPRO_NAME}
</title>
</svelte:head>
<FilesOverlay show={dragged} />
<div id="notes-container" class="w-full min-h-full h-full px-3 md:px-[18px]">
{#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 truncate">
{$i18n.t("This will delete")}
<span class=" font-semibold">{selectedNote.title}</span>.
</div>
</DeleteConfirmDialog>
<div class="flex flex-col gap-1 px-1 mt-1.5 mb-3">
<div class="flex justify-between items-center">
<div
class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0"
>
<div>
{$i18n.t("Notes")}
</div>
<div
class="text-lg font-medium text-gray-500 dark:text-gray-500"
>
{total}
</div>
</div>
<div class="flex w-full justify-end gap-1.5">
<button
class=" px-2 py-1.5 rounded-xl bg-black text-white dark:bg-white dark:text-black transition font-medium text-sm flex items-center"
on:click={async () => {
const res = await createNoteHandler(
dayjs().format("YYYY-MM-DD"),
);
if (res) {
goto(`/notes/${res.id}`);
}
}}
>
<Plus className="size-3" strokeWidth="2.5" />
<div class=" ml-1 text-xs">{$i18n.t("New Note")}</div>
</button>
</div>
</div>
</div>
<div
class="py-2 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30"
>
<div
class="px-3.5 flex flex-1 items-center w-full space-x-2 py-0.5 pb-2"
>
<div class="flex flex-1 items-center">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class=" w-full text-sm py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
placeholder={$i18n.t("Search Notes")}
/>
{#if query}
<div
class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent"
>
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
on:click={() => {
query = "";
}}
>
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
</div>
</div>
<div class="px-3 flex justify-between">
<div
class="flex w-full bg-transparent overflow-x-auto scrollbar-none"
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div
class="flex gap-3 w-fit text-center text-sm rounded-full bg-transparent px-0.5 whitespace-nowrap"
>
<DropdownOptions
align="start"
className="flex shrink-0 items-center gap-2 px-3 py-1.5 text-sm bg-gray-50 dark:bg-gray-850 rounded-xl placeholder-gray-400 outline-hidden focus:outline-hidden"
bind:value={viewOption}
items={[
{ value: null, label: $i18n.t("All") },
{
value: "created",
label: $i18n.t("Created by you"),
},
{
value: "shared",
label: $i18n.t("Shared with you"),
},
]}
onChange={(value) => {
if (value) {
localStorage.noteViewOption = value;
} else {
delete localStorage.noteViewOption;
}
}}
/>
{#if [null, "shared"].includes(viewOption)}
<DropdownOptions
align="start"
bind:value={permission}
items={[
{ value: null, label: $i18n.t("Write") },
{
value: "read_only",
label: $i18n.t("Read Only"),
},
]}
/>
{/if}
</div>
</div>
<div class="shrink-0">
<DropdownOptions
align="start"
bind:value={displayOption}
items={[
{ value: null, label: $i18n.t("List") },
{ value: "grid", label: $i18n.t("Grid") },
]}
onChange={() => {
if (displayOption) {
localStorage.noteDisplayOption = displayOption;
} else {
delete localStorage.noteDisplayOption;
}
}}
/>
</div>
</div>
{#if items !== null && total !== null}
{#if (items ?? []).length > 0}
{@const groupedNotes = groupNotes(items)}
<div class="@container h-full py-2.5 px-2.5">
<div class="">
{#each groupedNotes as [timeRange, notesList], idx}
<div
class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium px-2.5 pb-2.5"
>
{$i18n.t(timeRange)}
</div>
{#if displayOption === null}
<div
class="{groupedNotes.length - 1 !== idx
? 'mb-3'
: ''} gap-1.5 flex flex-col"
>
{#each notesList as note, idx (note.id)}
<div
class=" flex cursor-pointer w-full px-3.5 py-1.5 border border-gray-50 dark:border-gray-850/30 bg-transparent dark:hover:bg-gray-850 hover:bg-white rounded-2xl transition"
>
<a
href={`/notes/${note.id}`}
class="w-full flex flex-col justify-between"
>
<div class="flex-1">
<div
class=" flex items-center gap-2 self-center justify-between"
>
<Tooltip
content={note.title}
className="flex-1"
placement="top-start"
>
<div
class=" text-sm font-medium capitalize flex-1 w-full line-clamp-1"
>
{note.title}
</div>
</Tooltip>
<div
class="flex shrink-0 items-center text-xs gap-2.5"
>
<Tooltip
content={dayjs(
note.updated_at /
1000000,
).format(
"LLLL",
)}
>
<div>
{dayjs(
note.updated_at /
1000000,
).fromNow()}
</div>
</Tooltip>
<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>
<NoteMenu
onDownload={(
type,
) => {
selectedNote =
note;
downloadHandler(
type,
);
}}
onCopyLink={async () => {
const baseUrl =
window
.location
.origin;
const res =
await copyToClipboard(
`${baseUrl}/notes/${note.id}`,
);
if (
res
) {
toast.success(
$i18n.t(
"Copied link to clipboard",
),
);
} else {
toast.error(
$i18n.t(
"Failed to copy link",
),
);
}
}}
onDelete={() => {
selectedNote =
note;
showDeleteConfirm = true;
}}
isPinned={note.is_pinned ??
false}
onPin={async () => {
await toggleNotePinnedStatusById(
localStorage.token,
note.id,
);
pinnedNotes.set(
await getPinnedNoteList(
localStorage.token,
).catch(
() => [],
),
);
init();
}}
>
<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>
</div>
</a>
</div>
{/each}
</div>
{:else if displayOption === "grid"}
<div
class="{groupedNotes.length - 1 !== idx
? 'mb-5'
: ''} gap-2.5 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
>
{#each notesList as note, idx (note.id)}
<div
class=" flex space-x-4 cursor-pointer w-full px-4.5 py-4 border border-gray-50 dark:border-gray-850/30 bg-transparent dark:hover:bg-gray-850 hover:bg-white rounded-2xl 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,
);
}}
onCopyLink={async () => {
const baseUrl =
window
.location
.origin;
const res =
await copyToClipboard(
`${baseUrl}/notes/${note.id}`,
);
if (
res
) {
toast.success(
$i18n.t(
"Copied link to clipboard",
),
);
} else {
toast.error(
$i18n.t(
"Failed to copy link",
),
);
}
}}
onDelete={() => {
selectedNote =
note;
showDeleteConfirm = true;
}}
isPinned={note.is_pinned ??
false}
onPin={async () => {
await toggleNotePinnedStatusById(
localStorage.token,
note.id,
);
pinnedNotes.set(
await getPinnedNoteList(
localStorage.token,
).catch(
() => [],
),
);
init();
}}
>
<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-3 min-h-10"
>
{#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>
{/if}
{/each}
{#if !allItemsLoaded}
<Loader
on:visible={(e) => {
if (!itemsLoading) {
loadMoreItems();
}
}}
>
<div
class="w-full flex justify-center py-4 text-xs animate-pulse items-center gap-2"
>
<Spinner className=" size-4" />
<div class=" ">
{$i18n.t("Loading...")}
</div>
</div>
</Loader>
{/if}
</div>
</div>
{:else}
<div
class="w-full h-full flex flex-col items-center justify-center"
>
<div class="py-20 text-center">
<div
class=" text-sm text-gray-400 dark:text-gray-600"
>
{$i18n.t("No Notes")}
</div>
<div
class="mt-1 text-xs text-gray-300 dark:text-gray-700"
>
{$i18n.t(
"Create your first note by clicking on the plus button below.",
)}
</div>
</div>
</div>
{/if}
{:else}
<div
class="w-full h-full flex justify-center items-center py-10"
>
<Spinner className="size-4" />
</div>
{/if}
</div>
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner className="size-4" />
</div>
{/if}
</div>