mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-12 04:15:25 +00:00
enh/refac: notes
This commit is contained in:
parent
1ea555a5ac
commit
9b24cddef6
5 changed files with 688 additions and 317 deletions
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
############################
|
############################
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
54
src/lib/components/common/DropdownOptions.svelte
Normal file
54
src/lib/components/common/DropdownOptions.svelte
Normal 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>
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue