enh/refac: notes

This commit is contained in:
Timothy Jaeryang Baek 2025-12-09 16:45:08 -05:00
parent 1ea555a5ac
commit 9b24cddef6
5 changed files with 688 additions and 317 deletions

View file

@ -7,12 +7,15 @@ from functools import lru_cache
from open_webui.internal.db import Base, get_db from open_webui.internal.db import Base, get_db
from open_webui.models.groups import Groups from open_webui.models.groups import Groups
from open_webui.utils.access_control import has_access from open_webui.utils.access_control import has_access
from open_webui.models.users import Users, UserResponse from open_webui.models.users import User, UserModel, Users, UserResponse
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
from sqlalchemy import or_, func, select, and_, text from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import or_, func, select, and_, text, cast, or_, and_, func
from sqlalchemy.sql import exists from sqlalchemy.sql import exists
#################### ####################
@ -75,7 +78,63 @@ class NoteUserResponse(NoteModel):
user: Optional[UserResponse] = None user: Optional[UserResponse] = None
class NoteItemResponse(BaseModel):
id: str
title: str
data: Optional[dict]
updated_at: int
created_at: int
user: Optional[UserResponse] = None
class NoteListResponse(BaseModel):
items: list[NoteUserResponse]
total: int
class NoteTable: class NoteTable:
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
group_ids = filter.get("group_ids", [])
user_id = filter.get("user_id")
dialect_name = db.bind.dialect.name
# Public access
conditions = []
if group_ids or user_id:
conditions.extend(
[
Note.access_control.is_(None),
cast(Note.access_control, String) == "null",
]
)
# User-level permission
if user_id:
conditions.append(Note.user_id == user_id)
# Group-level permission
if group_ids:
group_conditions = []
for gid in group_ids:
if dialect_name == "sqlite":
group_conditions.append(
Note.access_control[permission]["group_ids"].contains([gid])
)
elif dialect_name == "postgresql":
group_conditions.append(
cast(
Note.access_control[permission]["group_ids"],
JSONB,
).contains([gid])
)
conditions.append(or_(*group_conditions))
if conditions:
query = query.filter(or_(*conditions))
return query
def insert_new_note( def insert_new_note(
self, self,
form_data: NoteForm, form_data: NoteForm,
@ -110,15 +169,103 @@ class NoteTable:
notes = query.all() notes = query.all()
return [NoteModel.model_validate(note) for note in notes] return [NoteModel.model_validate(note) for note in notes]
def search_notes(
self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30
) -> NoteListResponse:
with get_db() as db:
query = db.query(Note, User).outerjoin(User, User.id == Note.user_id)
if filter:
query_key = filter.get("query")
if query_key:
query = query.filter(
or_(
Note.title.ilike(f"%{query_key}%"),
Note.data["content"]["md"].ilike(f"%{query_key}%"),
)
)
view_option = filter.get("view_option")
if view_option == "created":
query = query.filter(Note.user_id == user_id)
elif view_option == "shared":
query = query.filter(Note.user_id != user_id)
# Apply access control filtering
query = self._has_permission(
db,
query,
filter,
permission="write",
)
order_by = filter.get("order_by")
direction = filter.get("direction")
if order_by == "name":
if direction == "asc":
query = query.order_by(Note.title.asc())
else:
query = query.order_by(Note.title.desc())
elif order_by == "created_at":
if direction == "asc":
query = query.order_by(Note.created_at.asc())
else:
query = query.order_by(Note.created_at.desc())
elif order_by == "updated_at":
if direction == "asc":
query = query.order_by(Note.updated_at.asc())
else:
query = query.order_by(Note.updated_at.desc())
else:
query = query.order_by(Note.updated_at.desc())
else:
query = query.order_by(Note.updated_at.desc())
for key, value in filter.items():
query = query.filter(getattr(Note, key).ilike(f"%{value}%"))
# Count BEFORE pagination
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
notes = []
for note, user in items:
notes.append(
NoteUserResponse(
**NoteModel.model_validate(note).model_dump(),
user=(
UserResponse(**UserModel.model_validate(user).model_dump())
if user
else None
),
)
)
return NoteListResponse(items=notes, total=total)
def get_notes_by_user_id( def get_notes_by_user_id(
self, self,
user_id: str, user_id: str,
permission: str = "read",
skip: Optional[int] = None, skip: Optional[int] = None,
limit: Optional[int] = None, limit: Optional[int] = None,
) -> list[NoteModel]: ) -> list[NoteModel]:
with get_db() as db: with get_db() as db:
query = db.query(Note).filter(Note.user_id == user_id) user_group_ids = [
query = query.order_by(Note.updated_at.desc()) group.id for group in Groups.get_groups_by_member_id(user_id)
]
query = db.query(Note).order_by(Note.updated_at.desc())
query = self._has_permission(
db, query, {"user_id": user_id, "group_ids": user_group_ids}, permission
)
if skip is not None: if skip is not None:
query = query.offset(skip) query = query.offset(skip)
@ -128,56 +275,6 @@ class NoteTable:
notes = query.all() notes = query.all()
return [NoteModel.model_validate(note) for note in notes] return [NoteModel.model_validate(note) for note in notes]
def get_notes_by_permission(
self,
user_id: str,
permission: str = "write",
skip: Optional[int] = None,
limit: Optional[int] = None,
) -> list[NoteModel]:
with get_db() as db:
user_groups = Groups.get_groups_by_member_id(user_id)
user_group_ids = {group.id for group in user_groups}
# Order newest-first. We stream to keep memory usage low.
query = (
db.query(Note)
.order_by(Note.updated_at.desc())
.execution_options(stream_results=True)
.yield_per(256)
)
results: list[NoteModel] = []
n_skipped = 0
for note in query:
# Fast-pass #1: owner
if note.user_id == user_id:
permitted = True
# Fast-pass #2: public/open
elif note.access_control is None:
# Technically this should mean public access for both read and write, but we'll only do read for now
# We might want to change this behavior later
permitted = permission == "read"
else:
permitted = has_access(
user_id, permission, note.access_control, user_group_ids
)
if not permitted:
continue
# Apply skip AFTER permission filtering so it counts only accessible notes
if skip and n_skipped < skip:
n_skipped += 1
continue
results.append(NoteModel.model_validate(note))
if limit is not None and len(results) >= limit:
break
return results
def get_note_by_id(self, id: str) -> Optional[NoteModel]: def get_note_by_id(self, id: str) -> Optional[NoteModel]:
with get_db() as db: with get_db() as db:
note = db.query(Note).filter(Note.id == id).first() note = db.query(Note).filter(Note.id == id).first()

View file

@ -8,11 +8,21 @@ from pydantic import BaseModel
from open_webui.socket.main import sio from open_webui.socket.main import sio
from open_webui.models.groups import Groups
from open_webui.models.users import Users, UserResponse from open_webui.models.users import Users, UserResponse
from open_webui.models.notes import Notes, NoteModel, NoteForm, NoteUserResponse from open_webui.models.notes import (
NoteListResponse,
Notes,
NoteModel,
NoteForm,
NoteUserResponse,
)
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT from open_webui.config import (
BYPASS_ADMIN_ACCESS_CONTROL,
ENABLE_ADMIN_CHAT_ACCESS,
ENABLE_ADMIN_EXPORT,
)
from open_webui.constants import ERROR_MESSAGES from open_webui.constants import ERROR_MESSAGES
from open_webui.env import SRC_LOG_LEVELS from open_webui.env import SRC_LOG_LEVELS
@ -30,39 +40,17 @@ router = APIRouter()
############################ ############################
@router.get("/", response_model=list[NoteUserResponse]) class NoteItemResponse(BaseModel):
async def get_notes(request: Request, user=Depends(get_verified_user)):
if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
notes = [
NoteUserResponse(
**{
**note.model_dump(),
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
}
)
for note in Notes.get_notes_by_permission(user.id, "write")
]
return notes
class NoteTitleIdResponse(BaseModel):
id: str id: str
title: str title: str
data: Optional[dict]
updated_at: int updated_at: int
created_at: int created_at: int
user: Optional[UserResponse] = None
@router.get("/list", response_model=list[NoteTitleIdResponse]) @router.get("/", response_model=list[NoteItemResponse])
async def get_note_list( async def get_notes(
request: Request, page: Optional[int] = None, user=Depends(get_verified_user) request: Request, page: Optional[int] = None, user=Depends(get_verified_user)
): ):
if user.role != "admin" and not has_permission( if user.role != "admin" and not has_permission(
@ -80,15 +68,61 @@ async def get_note_list(
skip = (page - 1) * limit skip = (page - 1) * limit
notes = [ notes = [
NoteTitleIdResponse(**note.model_dump()) NoteUserResponse(
for note in Notes.get_notes_by_permission( **{
user.id, "write", skip=skip, limit=limit **note.model_dump(),
"user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
}
) )
for note in Notes.get_notes_by_user_id(user.id, "read", skip=skip, limit=limit)
] ]
return notes return notes
@router.get("/search", response_model=NoteListResponse)
async def search_notes(
request: Request,
query: Optional[str] = None,
view_option: Optional[str] = None,
order_by: Optional[str] = None,
direction: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
):
if user.role != "admin" and not has_permission(
user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=ERROR_MESSAGES.UNAUTHORIZED,
)
limit = None
skip = None
if page is not None:
limit = 60
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
if view_option:
filter["view_option"] = view_option
if order_by:
filter["order_by"] = order_by
if direction:
filter["direction"] = direction
if not user.role == "admin" or not BYPASS_ADMIN_ACCESS_CONTROL:
groups = Groups.get_groups_by_member_id(user.id)
if groups:
filter["group_ids"] = [group.id for group in groups]
filter["user_id"] = user.id
return Notes.search_notes(user.id, filter, skip=skip, limit=limit)
############################ ############################
# CreateNewNote # CreateNewNote
############################ ############################

View file

@ -91,6 +91,60 @@ export const getNotes = async (token: string = '', raw: boolean = false) => {
return grouped; return grouped;
}; };
export const searchNotes = async (
token: string = '',
query: string | null = null,
viewOption: string | null = null,
sortKey: string | null = null,
page: number | null = null
) => {
let error = null;
const searchParams = new URLSearchParams();
if (query !== null) {
searchParams.append('query', query);
}
if (viewOption !== null) {
searchParams.append('view_option', viewOption);
}
if (sortKey !== null) {
searchParams.append('order_by', sortKey);
}
if (page !== null) {
searchParams.append('page', `${page}`);
}
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/search?${searchParams.toString()}`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
authorization: `Bearer ${token}`
}
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const getNoteList = async (token: string = '', page: number | null = null) => { export const getNoteList = async (token: string = '', page: number | null = null) => {
let error = null; let error = null;
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
@ -99,7 +153,7 @@ export const getNoteList = async (token: string = '', page: number | null = null
searchParams.append('page', `${page}`); searchParams.append('page', `${page}`);
} }
const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list?${searchParams.toString()}`, { const res = await fetch(`${WEBUI_API_BASE_URL}/notes/?${searchParams.toString()}`, {
method: 'GET', method: 'GET',
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',

View file

@ -0,0 +1,54 @@
<script lang="ts">
import { getContext } from 'svelte';
import { Select, DropdownMenu } from 'bits-ui';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
const i18n = getContext('i18n');
export let align = 'center';
export let className = '';
export let value = '';
export let placeholder = 'Select an option';
export let items = [
{ value: 'new', label: $i18n.t('New') },
{ value: 'top', label: $i18n.t('Top') }
];
let open = false;
</script>
<DropdownMenu.Root bind:open>
<DropdownMenu.Trigger>
<div
class={className
? className
: 'flex w-full items-center gap-2 truncate bg-transparent px-0.5 text-sm placeholder-gray-400 outline-hidden focus:outline-hidden'}
>
{items.find((item) => item.value === value)?.label ?? placeholder}
<ChevronDown className=" size-3" strokeWidth="2.5" />
</div>
</DropdownMenu.Trigger>
<DropdownMenu.Content {align}>
<div
class="dark:bg-gray-850 z-50 w-full rounded-2xl border border-gray-100 bg-white p-1 shadow-lg dark:border-gray-800 dark:text-white"
>
{#each items as item}
<button
class="flex w-full cursor-pointer items-center gap-2 rounded-xl px-3 py-1.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-800 {value ===
item.value
? ' '
: ' text-gray-500 dark:text-gray-400'}"
type="button"
on:click={() => {
value = item.value;
open = false;
}}
>
{item.label}
</button>
{/each}
</div>
</DropdownMenu.Content>
</DropdownMenu.Root>

View file

@ -1,9 +1,7 @@
<script lang="ts"> <script lang="ts">
import { marked } from 'marked'; import { marked } from 'marked';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import fileSaver from 'file-saver'; import fileSaver from 'file-saver';
import Fuse from 'fuse.js';
const { saveAs } = fileSaver; const { saveAs } = fileSaver;
@ -25,17 +23,16 @@
} }
} }
import { onMount, getContext, onDestroy } from 'svelte';
const i18n = getContext('i18n');
// Assuming $i18n.languages is an array of language codes // Assuming $i18n.languages is an array of language codes
$: loadLocale($i18n.languages); $: loadLocale($i18n.languages);
import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { onMount, getContext, onDestroy } from 'svelte';
import { WEBUI_NAME, config, prompts as _prompts, user } from '$lib/stores'; import { WEBUI_NAME, config, prompts as _prompts, user } from '$lib/stores';
import { createNewNote, deleteNoteById, getNoteList, searchNotes } from '$lib/apis/notes';
import { createNewNote, deleteNoteById, getNotes } from '$lib/apis/notes';
import { capitalizeFirstLetter, copyToClipboard, getTimeRange } from '$lib/utils'; import { capitalizeFirstLetter, copyToClipboard, getTimeRange } from '$lib/utils';
import { downloadPdf, createNoteHandler } from './utils'; import { downloadPdf, createNoteHandler } from './utils';
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte'; import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
@ -48,58 +45,31 @@
import NoteMenu from './Notes/NoteMenu.svelte'; import NoteMenu from './Notes/NoteMenu.svelte';
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte'; import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
import XMark from '../icons/XMark.svelte'; import XMark from '../icons/XMark.svelte';
import DropdownOptions from '../common/DropdownOptions.svelte';
import Loader from '../common/Loader.svelte';
const i18n = getContext('i18n');
let loaded = false; let loaded = false;
let importFiles = ''; let importFiles = '';
let query = '';
let noteItems = [];
let fuse = null;
let selectedNote = null; let selectedNote = null;
let notes = {};
$: if (fuse) {
notes = groupNotes(
query
? fuse.search(query).map((e) => {
return e.item;
})
: noteItems
);
}
let showDeleteConfirm = false; let showDeleteConfirm = false;
const groupNotes = (res) => { let notes = {};
console.log(res);
if (!Array.isArray(res)) {
return {}; // or throw new Error("Notes response is not an array")
}
// Build the grouped object let items = null;
const grouped: Record<string, any[]> = {}; let total = null;
for (const note of res) {
const timeRange = getTimeRange(note.updated_at / 1000000000);
if (!grouped[timeRange]) {
grouped[timeRange] = [];
}
grouped[timeRange].push({
...note,
timeRange
});
}
return grouped;
};
const init = async () => { let query = '';
noteItems = await getNotes(localStorage.token, true);
fuse = new Fuse(noteItems, { let sortKey = null;
keys: ['title'] let displayOption = null;
}); let viewOption = null;
}; let permission = null;
let page = 1;
let itemsLoading = false;
let allItemsLoaded = false;
const downloadHandler = async (type) => { const downloadHandler = async (type) => {
if (type === 'txt') { if (type === 'txt') {
@ -173,6 +143,81 @@
} }
}; };
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();
loaded = true;
};
$: if (query !== undefined && sortKey !== undefined && viewOption !== undefined) {
init();
}
const getItemsPage = async () => {
itemsLoading = true;
const res = await searchNotes(localStorage.token, query, viewOption, 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) {
items = [...items, ...pageItems];
} else {
items = pageItems;
}
}
itemsLoading = false;
return res;
};
const groupNotes = (res) => {
if (!Array.isArray(res)) {
return {}; // or throw new Error("Notes response is not an array")
}
// Build the grouped object
const grouped: Record<string, any[]> = {};
for (const note of res) {
const timeRange = getTimeRange(note.updated_at / 1000000000);
if (!grouped[timeRange]) {
grouped[timeRange] = [];
}
grouped[timeRange].push({
...note,
timeRange
});
}
return grouped;
};
let dragged = false; let dragged = false;
const onDragOver = (e) => { const onDragOver = (e) => {
@ -205,6 +250,13 @@
dragged = false; dragged = false;
}; };
onMount(async () => {
const dropzoneElement = document.getElementById('notes-container');
dropzoneElement?.addEventListener('dragover', onDragOver);
dropzoneElement?.addEventListener('drop', onDrop);
dropzoneElement?.addEventListener('dragleave', onDragLeave);
});
onDestroy(() => { onDestroy(() => {
console.log('destroy'); console.log('destroy');
const dropzoneElement = document.getElementById('notes-container'); const dropzoneElement = document.getElementById('notes-container');
@ -215,17 +267,6 @@
dropzoneElement?.removeEventListener('dragleave', onDragLeave); 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> </script>
<svelte:head> <svelte:head>
@ -236,7 +277,7 @@
<FilesOverlay show={dragged} /> <FilesOverlay show={dragged} />
<div id="notes-container" class="w-full min-h-full h-full"> <div id="notes-container" class="w-full min-h-full h-full px-3 md:px-[18px]">
{#if loaded} {#if loaded}
<DeleteConfirmDialog <DeleteConfirmDialog
bind:show={showDeleteConfirm} bind:show={showDeleteConfirm}
@ -251,8 +292,41 @@
</div> </div>
</DeleteConfirmDialog> </DeleteConfirmDialog>
<div class="flex flex-col gap-1 px-3.5"> <div class="flex flex-col gap-1 px-1 mt-1.5 mb-3">
<div class=" flex flex-1 items-center w-full space-x-2"> <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=" hidden md:block md: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="flex flex-1 items-center">
<div class=" self-center ml-1 mr-3"> <div class=" self-center ml-1 mr-3">
<Search className="size-3.5" /> <Search className="size-3.5" />
@ -277,16 +351,130 @@
{/if} {/if}
</div> </div>
</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-1.5 w-fit text-center text-sm rounded-full bg-transparent px-0.5 whitespace-nowrap"
>
<DropdownOptions
align="start"
className="flex w-full items-center gap-2 truncate 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') }
]}
/>
</div>
</div> </div>
<div class="px-4.5 @container h-full pt-2"> <div>
{#if Object.keys(notes).length > 0} <DropdownOptions
align="start"
bind:value={displayOption}
items={[
{ value: null, label: $i18n.t('List') },
{ value: 'grid', label: $i18n.t('Grid') }
]}
/>
</div>
</div>
{#if (items ?? []).length > 0}
{@const notes = groupNotes(items)}
<div class="@container h-full py-2 px-2.5">
<div class="pb-10"> <div class="pb-10">
{#each Object.keys(notes) as timeRange} {#each Object.keys(notes) as timeRange}
<div class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium pb-2.5"> <div
class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium px-2.5 pb-2.5"
>
{$i18n.t(timeRange)} {$i18n.t(timeRange)}
</div> </div>
{#if displayOption === null}
<div class="mb-3 gap-1.5 flex flex-col">
{#each notes[timeRange] 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">
<div class=" text-sm font-medium capitalize flex-1 w-full">
{note.title}
</div>
<div class="flex shrink-0 items-center text-xs gap-2.5">
<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>
<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;
}}
>
<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 <div
class="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" class="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"
> >
@ -312,7 +500,9 @@
}} }}
onCopyLink={async () => { onCopyLink={async () => {
const baseUrl = window.location.origin; const baseUrl = window.location.origin;
const res = await copyToClipboard(`${baseUrl}/notes/${note.id}`); const res = await copyToClipboard(
`${baseUrl}/notes/${note.id}`
);
if (res) { if (res) {
toast.success($i18n.t('Copied link to clipboard')); toast.success($i18n.t('Copied link to clipboard'));
@ -369,99 +559,41 @@
</div> </div>
{/each} {/each}
</div> </div>
{/if}
{/each} {/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> </div>
{:else} {:else}
<div class="w-full h-full flex flex-col items-center justify-center"> <div class="w-full h-full flex flex-col items-center justify-center">
<div class="pb-20 text-center"> <div class="py-20 text-center">
<div class=" text-xl font-medium text-gray-400 dark:text-gray-600"> <div class=" text-sm text-gray-400 dark:text-gray-600">
{$i18n.t('No Notes')} {$i18n.t('No Notes')}
</div> </div>
<div class="mt-1 text-sm text-gray-300 dark:text-gray-700"> <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.')} {$i18n.t('Create your first note by clicking on the plus button below.')}
</div> </div>
</div> </div>
</div> </div>
{/if} {/if}
</div> </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 () => {
const res = await createNoteHandler(dayjs().format('YYYY-MM-DD'));
if (res) {
goto(`/notes/${res.id}`);
}
}}
>
<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} {:else}
<div class="w-full h-full flex justify-center items-center"> <div class="w-full h-full flex justify-center items-center">
<Spinner className="size-5" /> <Spinner className="size-5" />