feat: frontend finetuning
184
web/src/app.css
@ -12,6 +12,11 @@
|
|||||||
--dark-text: rgb(238, 238, 238);
|
--dark-text: rgb(238, 238, 238);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scrollbar width */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
@apply bg-[var(--light-background)] text-[var(--light-text)] transition-colors duration-200;
|
@apply bg-[var(--light-background)] text-[var(--light-text)] transition-colors duration-200;
|
||||||
@ -58,7 +63,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
input:focus,
|
input:focus,
|
||||||
textarea:focus,
|
|
||||||
select:focus {
|
select:focus {
|
||||||
@apply ring-2 ring-[var(--light-accent)] outline-none;
|
@apply ring-2 ring-[var(--light-accent)] outline-none;
|
||||||
}
|
}
|
||||||
@ -70,7 +74,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark input:focus,
|
.dark input:focus,
|
||||||
.dark textarea:focus,
|
|
||||||
.dark select:focus {
|
.dark select:focus {
|
||||||
@apply ring-[var(--dark-accent)];
|
@apply ring-[var(--dark-accent)];
|
||||||
}
|
}
|
||||||
@ -166,11 +169,11 @@
|
|||||||
|
|
||||||
/* Sidebar */
|
/* Sidebar */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
@apply fixed flex h-full w-64 flex-col overflow-hidden bg-[var(--light-foreground)] transition-all duration-300;
|
@apply fixed flex h-full w-64 flex-col overflow-hidden border-r border-[var(--light-foreground)] bg-[var(--light-foreground)] transition-all duration-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .sidebar {
|
.dark .sidebar {
|
||||||
@apply bg-[var(--dark-foreground)];
|
@apply border-[var(--dark-foreground)] bg-[var(--dark-foreground)];
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header,
|
.sidebar-header,
|
||||||
@ -223,9 +226,29 @@
|
|||||||
@apply divide-[var(--dark-text)]/20;
|
@apply divide-[var(--dark-text)]/20;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
@apply w-full rounded-md py-2 pr-3 pl-9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar-icon {
|
||||||
|
@apply absolute top-3 left-7 h-4 w-4 text-[var(--light-text)]/60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .search-bar-icon {
|
||||||
|
@apply text-[var(--dark-text)]/60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.general-sidebar-icon {
|
||||||
|
@apply h-4 w-4 text-[var(--light-text)]/60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .general-sidebar-icon {
|
||||||
|
@apply text-[var(--dark-text)]/60;
|
||||||
|
}
|
||||||
|
|
||||||
/* Note editor */
|
/* Note editor */
|
||||||
.note-title-input {
|
.note-title-input {
|
||||||
@apply w-full border-b border-[var(--light-text)]/20 bg-transparent pb-2 text-2xl font-bold focus:border-[var(--light-accent)];
|
@apply w-full rounded-2xl border-b border-[var(--light-text)]/20 bg-transparent pb-2 text-2xl font-bold focus:border-[var(--light-accent)];
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .note-title-input {
|
.dark .note-title-input {
|
||||||
@ -233,7 +256,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.note-char-count {
|
.note-char-count {
|
||||||
@apply mt-1 text-xs text-[var(--light-text)]/60;
|
@apply mt-2 text-xs text-[var(--light-text)]/60;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .note-char-count {
|
.dark .note-char-count {
|
||||||
@ -241,11 +264,154 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.note-textarea {
|
.note-textarea {
|
||||||
@apply h-full min-h-[300px] w-full resize-none bg-[var(--light-background)] p-4 font-mono;
|
@apply h-full max-h-full w-full resize-none rounded-2xl bg-transparent p-3.5 font-mono outline-none focus:border-4 focus:border-[var(--light-accent)]/60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-save-button {
|
||||||
|
@apply absolute right-10 bottom-10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .note-textarea {
|
.dark .note-textarea {
|
||||||
@apply bg-[var(--dark-background)];
|
@apply focus:border-[var(--dark-accent)]/60;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown preview */
|
||||||
|
.markdown-preview h1 {
|
||||||
|
@apply mt-6 mb-4 border-b border-[var(--light-foreground)] pb-3 text-3xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .markdown-preview h1 {
|
||||||
|
@apply border-[var(--dark-foreground)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview h2 {
|
||||||
|
@apply mt-6 mb-4 text-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview h3 {
|
||||||
|
@apply mt-5 mb-3 text-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview p {
|
||||||
|
@apply my-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview ul,
|
||||||
|
.markdown-preview ol {
|
||||||
|
@apply my-4 pl-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview ul {
|
||||||
|
@apply list-disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview ol {
|
||||||
|
@apply list-decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview li > ul,
|
||||||
|
.markdown-preview li > ol,
|
||||||
|
.markdown-preview ul > ul,
|
||||||
|
.markdown-preview ul > ol,
|
||||||
|
.markdown-preview ol > ol,
|
||||||
|
.markdown-preview ol > ul {
|
||||||
|
@apply my-1; /* Reduced vertical spacing for nested lists */
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview ul ul:has(> li > input[type="checkbox"]) {
|
||||||
|
@apply pl-11;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview ul ul ul:has(> li > input[type="checkbox"]) {
|
||||||
|
@apply pl-11;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview li span {
|
||||||
|
@apply ml-1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview li:has(> input[type="checkbox"]) {
|
||||||
|
/* Bullet removal */
|
||||||
|
@apply -ml-4.5 list-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview input[type="checkbox"] {
|
||||||
|
/* Actual checkbox styling */
|
||||||
|
@apply mr-1 h-4 w-4 appearance-none rounded-full border-[var(--light-text)]/30 bg-[var(--light-foreground)] p-0 align-middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .markdown-preview input[type="checkbox"] {
|
||||||
|
@apply border-[var(--dark-text)]/30 bg-[var(--dark-foreground)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview input[type="checkbox"]:checked {
|
||||||
|
@apply border-[var(--light-accent)] bg-[var(--light-accent)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .markdown-preview input[type="checkbox"]:checked {
|
||||||
|
@apply border-[var(--dark-accent)] bg-[var(--dark-accent)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview hr {
|
||||||
|
@apply border-[var(--light-text)]/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .markdown-preview hr {
|
||||||
|
@apply border-[var(--dark-text)]/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview code {
|
||||||
|
@apply rounded bg-[var(--light-foreground)] px-1 py-0.5 font-mono;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .markdown-preview code {
|
||||||
|
@apply bg-[var(--dark-foreground)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview pre {
|
||||||
|
@apply overflow-x-auto rounded-2xl bg-[var(--light-foreground)] p-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .markdown-preview pre {
|
||||||
|
@apply bg-[var(--dark-foreground)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview blockquote {
|
||||||
|
@apply my-4 border-l-4 border-[var(--light-accent)] pl-4 text-[var(--light-text)] opacity-70;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .markdown-preview blockquote {
|
||||||
|
@apply border-[var(--dark-accent)] text-[var(--dark-text)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview a {
|
||||||
|
@apply text-[var(--light-accent)] underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .markdown-preview a {
|
||||||
|
@apply text-[var(--dark-accent)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview table {
|
||||||
|
@apply my-4 w-full border-collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview th,
|
||||||
|
.markdown-preview td {
|
||||||
|
@apply border border-[var(--light-text)]/20 p-2 text-left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .markdown-preview th,
|
||||||
|
.dark .markdown-preview td {
|
||||||
|
@apply border-[var(--dark-text)]/20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview th {
|
||||||
|
@apply bg-[var(--light-foreground)];
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .markdown-preview th {
|
||||||
|
@apply bg-[var(--dark-foreground)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Settings modal */
|
/* Settings modal */
|
||||||
@ -292,7 +458,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark .main-header {
|
.dark .main-header {
|
||||||
@apply flex items-center justify-between bg-[var(--dark-foreground)] p-4 shadow-sm;
|
@apply bg-[var(--dark-foreground)];
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-content {
|
.main-content {
|
||||||
|
@ -3,7 +3,7 @@ import { API_BASE_ADDR, AT_EXP_MS, CSRF_EXP_MS, REFRESH_BUF, UUID_REGEX } from "
|
|||||||
import { goto } from "$app/navigation"
|
import { goto } from "$app/navigation"
|
||||||
import { usersPagination } from "./pages"
|
import { usersPagination } from "./pages"
|
||||||
|
|
||||||
interface UserResponse {
|
interface User {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
@ -11,7 +11,15 @@ interface UserResponse {
|
|||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FullNoteResponse {
|
interface ApiUserResponse {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
is_admin: boolean
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FullNote {
|
||||||
id: string
|
id: string
|
||||||
owner: string
|
owner: string
|
||||||
title: string
|
title: string
|
||||||
@ -22,13 +30,31 @@ export interface FullNoteResponse {
|
|||||||
noteUpdatedAt: Date
|
noteUpdatedAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NoteMetadataResponse {
|
interface ApiFullNoteResponse {
|
||||||
|
note_id: string
|
||||||
|
owner_id: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
version_number: number
|
||||||
|
version_created_at: string
|
||||||
|
note_created_at: string
|
||||||
|
note_updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NoteMetadata {
|
||||||
id: string
|
id: string
|
||||||
owner: string
|
owner: string
|
||||||
title: string
|
title: string
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ApiNoteMetadataResponse {
|
||||||
|
note_id: string
|
||||||
|
owner_id: string
|
||||||
|
title: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
interface NewNoteResponse {
|
interface NewNoteResponse {
|
||||||
title: string
|
title: string
|
||||||
content: string
|
content: string
|
||||||
@ -47,9 +73,9 @@ interface FullVersionResponse {
|
|||||||
createdAt: Date
|
createdAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
export const currentUser: Writable<UserResponse | null> = writable(null)
|
export const currentUser: Writable<User | null> = writable(null)
|
||||||
export const currentFullNote: Writable<FullNoteResponse | null> = writable(null)
|
export const currentFullNote: Writable<FullNote | null> = writable(null)
|
||||||
export const availableNotes: Writable<NoteMetadataResponse[] | null> = writable(null)
|
export const availableNotes: Writable<NoteMetadata[] | null> = writable(null)
|
||||||
export const accessToken: Writable<string | null> = writable(null)
|
export const accessToken: Writable<string | null> = writable(null)
|
||||||
export const csrfToken: Writable<string | null> = writable(null)
|
export const csrfToken: Writable<string | null> = writable(null)
|
||||||
export const isPending: Writable<boolean> = writable(false)
|
export const isPending: Writable<boolean> = writable(false)
|
||||||
@ -235,6 +261,40 @@ class ApiClient {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private deserializeUser(apiResponse: ApiUserResponse): User {
|
||||||
|
return {
|
||||||
|
id: apiResponse.id,
|
||||||
|
username: apiResponse.username,
|
||||||
|
isAdmin: apiResponse.is_admin,
|
||||||
|
createdAt: new Date(apiResponse.created_at),
|
||||||
|
updatedAt: new Date(apiResponse.updated_at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private deserializeNoteMetadatas(apiResponses: ApiNoteMetadataResponse[]): NoteMetadata[] {
|
||||||
|
return apiResponses.map((res) => {
|
||||||
|
return {
|
||||||
|
id: res.note_id,
|
||||||
|
owner: res.owner_id,
|
||||||
|
title: res.title,
|
||||||
|
updatedAt: new Date(res.updated_at)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private deserializeFullNote(apiResponse: ApiFullNoteResponse): FullNote {
|
||||||
|
return {
|
||||||
|
id: apiResponse.note_id,
|
||||||
|
owner: apiResponse.owner_id,
|
||||||
|
title: apiResponse.title,
|
||||||
|
content: apiResponse.content,
|
||||||
|
versionNumber: apiResponse.version_number,
|
||||||
|
versionCreatedAt: new Date(apiResponse.version_created_at),
|
||||||
|
noteCreatedAt: new Date(apiResponse.note_created_at),
|
||||||
|
noteUpdatedAt: new Date(apiResponse.note_updated_at)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async register(username: string, password: string): Promise<void> {
|
public async register(username: string, password: string): Promise<void> {
|
||||||
return this.handleRequest(
|
return this.handleRequest(
|
||||||
async () => {
|
async () => {
|
||||||
@ -260,25 +320,20 @@ class ApiClient {
|
|||||||
public async login(username: string, password: string): Promise<void> {
|
public async login(username: string, password: string): Promise<void> {
|
||||||
return this.handleRequest(
|
return this.handleRequest(
|
||||||
async () => {
|
async () => {
|
||||||
const params = new URLSearchParams()
|
const response = await fetch(`${this.baseUrl}/auth/login`, {
|
||||||
params.append("includeUser", "true")
|
|
||||||
const response = await fetch(`${this.baseUrl}/auth/login?${params}`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
credentials: "include"
|
credentials: "include"
|
||||||
})
|
})
|
||||||
|
|
||||||
const { access_token: token, user } = await this.handleResponse<{
|
const { access_token: token } = await this.handleResponse<{
|
||||||
access_token: string
|
access_token: string
|
||||||
user: UserResponse
|
|
||||||
}>(response, { useBearerAuth: false })
|
}>(response, { useBearerAuth: false })
|
||||||
|
|
||||||
accessToken.set(token)
|
accessToken.set(token)
|
||||||
currentUser.set(user || null)
|
|
||||||
this.lastAtUpdate = new Date()
|
this.lastAtUpdate = new Date()
|
||||||
|
|
||||||
console.log(user)
|
|
||||||
goto("/")
|
goto("/")
|
||||||
},
|
},
|
||||||
{ useBearerAuth: false }
|
{ useBearerAuth: false }
|
||||||
@ -317,7 +372,9 @@ class ApiClient {
|
|||||||
...this.getAuthHeader()
|
...this.getAuthHeader()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const user = await this.handleResponse<UserResponse>(response, { useBearerAuth: false })
|
const data = await this.handleResponse<ApiUserResponse>(response, { useBearerAuth: false })
|
||||||
|
const user = this.deserializeUser(data)
|
||||||
|
console.log(user)
|
||||||
currentUser.set(user)
|
currentUser.set(user)
|
||||||
},
|
},
|
||||||
{ useBearerAuth: true }
|
{ useBearerAuth: true }
|
||||||
@ -334,13 +391,14 @@ class ApiClient {
|
|||||||
const response = await fetch(`${this.baseUrl}/auth/owner`, {
|
const response = await fetch(`${this.baseUrl}/auth/owner`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
...this.getAuthHeader()
|
...this.getAuthHeader(),
|
||||||
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data)
|
||||||
})
|
})
|
||||||
const { accessToken: token, user } = await this.handleResponse<{
|
const { accessToken: token, user } = await this.handleResponse<{
|
||||||
accessToken: string
|
accessToken: string
|
||||||
user: UserResponse
|
user: User
|
||||||
}>(response, { useBearerAuth: false })
|
}>(response, { useBearerAuth: false })
|
||||||
accessToken.set(token)
|
accessToken.set(token)
|
||||||
currentUser.set(user || null)
|
currentUser.set(user || null)
|
||||||
@ -359,7 +417,7 @@ class ApiClient {
|
|||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: {
|
headers: {
|
||||||
...this.getAuthHeader(),
|
...this.getAuthHeader(),
|
||||||
credentials: "include"
|
"Content-Type": "application/json"
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ password })
|
body: JSON.stringify({ password })
|
||||||
})
|
})
|
||||||
@ -377,7 +435,7 @@ class ApiClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public async adminListAll(): Promise<UserResponse[] | undefined> {
|
public async adminListAll(): Promise<User[] | undefined> {
|
||||||
const user = get(currentUser)
|
const user = get(currentUser)
|
||||||
if (!user || !user.isAdmin) {
|
if (!user || !user.isAdmin) {
|
||||||
throw new Error("Admin privileges required.")
|
throw new Error("Admin privileges required.")
|
||||||
@ -394,7 +452,7 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const users = await this.handleResponse<UserResponse[]>(response, { useBearerAuth: false })
|
const users = await this.handleResponse<User[]>(response, { useBearerAuth: false })
|
||||||
console.log(`admin: got ${users.length} user results`)
|
console.log(`admin: got ${users.length} user results`)
|
||||||
|
|
||||||
return users
|
return users
|
||||||
@ -433,7 +491,7 @@ class ApiClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public async listNotes(): Promise<NoteMetadataResponse[] | undefined> {
|
public async listNotes(): Promise<NoteMetadata[] | undefined> {
|
||||||
return this.handleRequest(
|
return this.handleRequest(
|
||||||
async () => {
|
async () => {
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
@ -445,11 +503,15 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// TODO: handle case where no notes have yet been created, i.e. `notes` from response is `null`
|
let notes: NoteMetadata[] = []
|
||||||
|
let data = await this.handleResponse<ApiNoteMetadataResponse[]>(response, {
|
||||||
const notes = await this.handleResponse<NoteMetadataResponse[]>(response, {
|
|
||||||
useBearerAuth: false
|
useBearerAuth: false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
notes = this.deserializeNoteMetadatas(data)
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`got ${notes.length} note metadata results`)
|
console.log(`got ${notes.length} note metadata results`)
|
||||||
|
|
||||||
return notes
|
return notes
|
||||||
@ -476,19 +538,27 @@ class ApiClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getFullNote(noteID: string): Promise<FullNoteResponse | undefined> {
|
public async getFullNote(noteID: string): Promise<FullNote | undefined> {
|
||||||
if (!UUID_REGEX.test(noteID)) {
|
if (!UUID_REGEX.test(noteID)) {
|
||||||
throw new Error("Invalid note ID format.")
|
throw new Error("Invalid note ID format.")
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.handleRequest(
|
return this.handleRequest(
|
||||||
async () => {
|
async () => {
|
||||||
const response = await fetch(`${this.baseUrl}/${noteID}`, {
|
const response = await fetch(`${this.baseUrl}/notes/${noteID}`, {
|
||||||
headers: {
|
headers: {
|
||||||
...this.getAuthHeader()
|
...this.getAuthHeader()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return await this.handleResponse<FullNoteResponse>(response, { useBearerAuth: false })
|
|
||||||
|
const data = await this.handleResponse<ApiFullNoteResponse>(response, {
|
||||||
|
useBearerAuth: false
|
||||||
|
})
|
||||||
|
const note = this.deserializeFullNote(data)
|
||||||
|
|
||||||
|
console.log(note)
|
||||||
|
|
||||||
|
return note
|
||||||
},
|
},
|
||||||
{ useBearerAuth: true }
|
{ useBearerAuth: true }
|
||||||
)
|
)
|
||||||
@ -555,8 +625,10 @@ class ApiClient {
|
|||||||
const response = await fetch(`${this.baseUrl}/notes/${noteID}/versions`, {
|
const response = await fetch(`${this.baseUrl}/notes/${noteID}/versions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
...this.getAuthHeader()
|
...this.getAuthHeader(),
|
||||||
}
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ title, content })
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
import { cError } from "$lib/client"
|
import { cError } from "$lib/client"
|
||||||
import { isPasswordValid } from "$lib/utils"
|
import { isPasswordValid } from "$lib/utils"
|
||||||
import { onMount } from "svelte"
|
import { onMount } from "svelte"
|
||||||
|
import ThemeToggle from "./ThemeToggle.svelte"
|
||||||
export let formName: string
|
export let formName: string
|
||||||
export let handler: (username: string, password: string) => Promise<void>
|
export let handler: (username: string, password: string) => Promise<void>
|
||||||
export let bottomText: string
|
export let bottomText: string
|
||||||
@ -49,7 +50,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex min-h-screen items-center justify-center px-4">
|
<div class="flex min-h-screen items-center justify-center px-4">
|
||||||
<div class="card rounded-4x1 grid w-auto max-w-md justify-items-center space-y-6">
|
<div class="card grid w-auto max-w-md justify-items-center space-y-6 rounded-3xl">
|
||||||
{#if $cError}
|
{#if $cError}
|
||||||
<div class="error">
|
<div class="error">
|
||||||
{$cError}
|
{$cError}
|
||||||
@ -92,3 +93,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute right-4 top-4">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte"
|
import { onMount } from "svelte"
|
||||||
import { marked } from "marked"
|
import { marked, Renderer } from "marked"
|
||||||
import { TITLE_MAX_LENGTH } from "$lib/const"
|
import { TITLE_MAX_LENGTH } from "$lib/const"
|
||||||
import type { FullNoteResponse } from "$lib/client"
|
import type { FullNote } from "$lib/client"
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
export let note: FullNoteResponse
|
export let note: FullNote
|
||||||
export let isEditing = false
|
export let isEditing = false
|
||||||
export let saveNote: (title: string, content: string) => Promise<void>
|
export let saveNote: (title: string, content: string) => Promise<void>
|
||||||
|
|
||||||
// Local copy for editing (to prevent uploading every single keypress as a unique version)
|
// Local copy for editing (to prevent uploading every single keypress as unique version)
|
||||||
let editableTitle = note.title
|
let editableTitle = note.title
|
||||||
let editableContent = note.content
|
let editableContent = note.content
|
||||||
|
|
||||||
@ -22,13 +22,13 @@
|
|||||||
const handleContentChange = (
|
const handleContentChange = (
|
||||||
event: Event & { currentTarget: EventTarget & HTMLTextAreaElement }
|
event: Event & { currentTarget: EventTarget & HTMLTextAreaElement }
|
||||||
) => {
|
) => {
|
||||||
if (!event.target) return
|
if (!event.target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const { value } = event.target as HTMLTextAreaElement
|
const { value } = event.target as HTMLTextAreaElement
|
||||||
editableContent = value
|
editableContent = value
|
||||||
|
|
||||||
// TODO: assure this is the correct implementation & max. title length restriction is
|
|
||||||
// applied correctly before sending anything out
|
|
||||||
|
|
||||||
// Update title based on the first line if it starts with #
|
// Update title based on the first line if it starts with #
|
||||||
const firstLine = editableContent.split("\n")[0]
|
const firstLine = editableContent.split("\n")[0]
|
||||||
if (firstLine && firstLine.startsWith("# ")) {
|
if (firstLine && firstLine.startsWith("# ")) {
|
||||||
@ -51,14 +51,37 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleTitleChange = (event: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
|
const handleTitleChange = (event: Event & { currentTarget: EventTarget & HTMLInputElement }) => {
|
||||||
if (!event.target) return
|
if (!event.target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const { value } = event.target as HTMLInputElement
|
const { value } = event.target as HTMLInputElement
|
||||||
editableTitle = value.slice(0, TITLE_MAX_LENGTH)
|
editableTitle = value.slice(0, TITLE_MAX_LENGTH)
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseMarkdown = (markdown: string) => {
|
const parseMarkdown = async (markdown: string) => {
|
||||||
if (!markdown) return ""
|
if (!markdown) {
|
||||||
return marked(markdown)
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable Github flavored markdown rendering
|
||||||
|
marked.setOptions({
|
||||||
|
gfm: true,
|
||||||
|
breaks: true
|
||||||
|
})
|
||||||
|
|
||||||
|
let html = await marked(markdown)
|
||||||
|
|
||||||
|
// Add spans to regular list items
|
||||||
|
html = html.replaceAll(/\<li\>([^\<].*)\<\/li\>/g, '<li><span class="list-text">$1</span></li>')
|
||||||
|
|
||||||
|
// Add spans to nested ordered/unordered lists
|
||||||
|
html = html.replaceAll(
|
||||||
|
/<li>([^<]+)(?=.*?<(?:ul|ol)[^>]*>)/g,
|
||||||
|
'<li><span class="list-text">$1</span></li>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return html
|
||||||
}
|
}
|
||||||
|
|
||||||
let textarea: HTMLTextAreaElement | null
|
let textarea: HTMLTextAreaElement | null
|
||||||
@ -71,6 +94,8 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (isEditing && textarea) {
|
if (isEditing && textarea) {
|
||||||
|
// Scrollbar is hidden in global CSS so flickering during resizing of the
|
||||||
|
// textarea shouldn't be an issue anymore
|
||||||
autoResize()
|
autoResize()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -80,6 +105,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- TODO: capture Ctrl+Enter keyboard shortcut to save the note contents -->
|
||||||
|
|
||||||
<div class="flex h-full flex-col">
|
<div class="flex h-full flex-col">
|
||||||
<!-- Note title -->
|
<!-- Note title -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
@ -88,23 +115,31 @@
|
|||||||
type="text"
|
type="text"
|
||||||
bind:value={editableTitle}
|
bind:value={editableTitle}
|
||||||
on:input={handleTitleChange}
|
on:input={handleTitleChange}
|
||||||
placeholder="Note title"
|
placeholder="Title"
|
||||||
class="note-title-input"
|
class="note-title-input"
|
||||||
/>
|
/>
|
||||||
<div class="note-char-count">
|
<div class="note-char-count ml-3">
|
||||||
{editableTitle.length}/{TITLE_MAX_LENGTH} characters
|
{editableTitle.length}/{TITLE_MAX_LENGTH} characters
|
||||||
</div>
|
</div>
|
||||||
|
<div class="note-char-count ml-3">
|
||||||
|
Last updated: {new Date(note.noteUpdatedAt).toLocaleString()}
|
||||||
|
{#if !isEditing}
|
||||||
|
<!-- Minus 1 due to versioning beginning at 2 in the DB -->
|
||||||
|
• Version: {note.versionNumber - 1}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<h1 class="border-[var(--light-text)]/20 border-b pb-2 text-2xl font-bold">
|
<h1 class="border-[var(--light-text)]/20 border-b pb-2 text-2xl font-bold">
|
||||||
{note.title || "Untitled Note"}
|
{note.title || "Untitled Note"}
|
||||||
</h1>
|
</h1>
|
||||||
{/if}
|
<div class="note-char-count ml-1">
|
||||||
<div class="note-char-count">
|
|
||||||
Last updated: {new Date(note.noteUpdatedAt).toLocaleString()}
|
Last updated: {new Date(note.noteUpdatedAt).toLocaleString()}
|
||||||
{#if !isEditing}
|
{#if !isEditing}
|
||||||
• Version: {note.versionNumber}
|
<!-- Minus 1 due to versioning beginning at 2 in the DB -->
|
||||||
|
• Version: {note.versionNumber - 1}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Note content -->
|
<!-- Note content -->
|
||||||
@ -115,127 +150,23 @@
|
|||||||
bind:this={textarea}
|
bind:this={textarea}
|
||||||
bind:value={editableContent}
|
bind:value={editableContent}
|
||||||
on:input={handleContentChange}
|
on:input={handleContentChange}
|
||||||
placeholder="Write your markdown note here..."
|
placeholder="Markdown contents"
|
||||||
class="note-textarea"
|
class="note-textarea"
|
||||||
></textarea>
|
></textarea>
|
||||||
<div class="absolute bottom-4 right-4">
|
|
||||||
<button on:click={handleSave} class="btn-primary"> Save </button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Rendered markdown preview -->
|
<!-- Rendered markdown preview -->
|
||||||
<div class="prose markdown-preview max-w-none p-4">
|
<div class="prose markdown-preview max-w-none p-4">
|
||||||
{@html parseMarkdown(note.content)}
|
{#await parseMarkdown(note.content) then html}
|
||||||
|
{@html html}
|
||||||
|
{/await}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if isEditing}
|
||||||
|
<div class="note-save-button">
|
||||||
|
<button on:click={handleSave} class="btn-primary rounded-full"> Save </button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
|
||||||
:global(.markdown-preview h1) {
|
|
||||||
font-size: 1.8rem;
|
|
||||||
margin: 1.5rem 0 1rem;
|
|
||||||
padding-bottom: 0.3rem;
|
|
||||||
border-bottom: 1px solid var(--light-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark .markdown-preview h1) {
|
|
||||||
border-bottom-color: var(--dark-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown-preview h2) {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
margin: 1.5rem 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown-preview h3) {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
margin: 1.2rem 0 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown-preview p) {
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown-preview ul, .markdown-preview ol) {
|
|
||||||
margin: 1rem 0;
|
|
||||||
padding-left: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown-preview ul) {
|
|
||||||
list-style-type: disc;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown-preview ol) {
|
|
||||||
list-style-type: decimal;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown-preview code) {
|
|
||||||
font-family: monospace;
|
|
||||||
padding: 0.2rem 0.4rem;
|
|
||||||
border-radius: 3px;
|
|
||||||
background-color: var(--light-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark .markdown-preview code) {
|
|
||||||
background-color: var(--dark-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown-preview pre) {
|
|
||||||
background-color: var(--light-foreground);
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 5px;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark .markdown-preview pre) {
|
|
||||||
background-color: var(--dark-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown-preview blockquote) {
|
|
||||||
border-left: 4px solid var(--light-accent);
|
|
||||||
padding-left: 1rem;
|
|
||||||
margin: 1rem 0;
|
|
||||||
color: var(--light-text);
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark .markdown-preview blockquote) {
|
|
||||||
border-left-color: var(--dark-accent);
|
|
||||||
color: var(--dark-text);
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown-preview a) {
|
|
||||||
color: var(--light-accent);
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark .markdown-preview a) {
|
|
||||||
color: var(--dark-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown-preview table) {
|
|
||||||
border-collapse: collapse;
|
|
||||||
width: 100%;
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown-preview th, .markdown-preview td) {
|
|
||||||
border: 1px solid rgba(var(--light-text-rgb, 34, 40, 49), 0.2);
|
|
||||||
padding: 8px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark .markdown-preview th, .dark .markdown-preview td) {
|
|
||||||
border: 1px solid rgba(var(--dark-text-rgb, 238, 238, 238), 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.markdown-preview th) {
|
|
||||||
background-color: var(--light-foreground);
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.dark .markdown-preview th) {
|
|
||||||
background-color: var(--dark-foreground);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
import {
|
import {
|
||||||
apiClient,
|
apiClient,
|
||||||
currentUser,
|
currentUser,
|
||||||
isPending,
|
// isPending,
|
||||||
currentFullNote,
|
currentFullNote,
|
||||||
availableNotes,
|
availableNotes,
|
||||||
cError
|
cError
|
||||||
@ -32,6 +32,7 @@
|
|||||||
|
|
||||||
// If still no current user after the fetch attempt, redirect to login
|
// If still no current user after the fetch attempt, redirect to login
|
||||||
if (!$currentUser) {
|
if (!$currentUser) {
|
||||||
|
console.log("no user data found, routing to auth page")
|
||||||
goto("/login")
|
goto("/login")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -47,7 +48,9 @@
|
|||||||
|
|
||||||
const loadNotes = async () => {
|
const loadNotes = async () => {
|
||||||
const notes = await apiClient.listNotes()
|
const notes = await apiClient.listNotes()
|
||||||
|
|
||||||
if (notes) {
|
if (notes) {
|
||||||
|
console.log(notes)
|
||||||
availableNotes.set(notes)
|
availableNotes.set(notes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -62,23 +65,28 @@
|
|||||||
|
|
||||||
const createNewNote = async () => {
|
const createNewNote = async () => {
|
||||||
const newNote = await apiClient.createNote()
|
const newNote = await apiClient.createNote()
|
||||||
|
|
||||||
if (newNote) {
|
if (newNote) {
|
||||||
// Refresh notes list
|
// Refresh notes list
|
||||||
await loadNotes()
|
await loadNotes()
|
||||||
|
|
||||||
// Get the full note details of the newly created note
|
// Get the full note details of the newly created note
|
||||||
// We need to find the ID from the updated availableNotes
|
// (we need to find the ID from the updated `availableNotes`)
|
||||||
if ($availableNotes && $availableNotes.length > 0) {
|
if ($availableNotes && $availableNotes.length > 0) {
|
||||||
const latestNote = $availableNotes[0] // Assuming the latest note is first
|
const latestNote = $availableNotes[0] // Assuming the latest note is first
|
||||||
await selectNote(latestNote.id)
|
await selectNote(latestNote.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
isEditing = true
|
isEditing = true // Open brand new notes in edit mode by default
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: add caching (!!!)
|
||||||
|
|
||||||
const selectNote = async (noteId: string) => {
|
const selectNote = async (noteId: string) => {
|
||||||
|
console.log(`loading ${noteId}`)
|
||||||
const note = await apiClient.getFullNote(noteId)
|
const note = await apiClient.getFullNote(noteId)
|
||||||
|
|
||||||
if (note) {
|
if (note) {
|
||||||
currentFullNote.set(note)
|
currentFullNote.set(note)
|
||||||
isEditing = false
|
isEditing = false
|
||||||
@ -109,15 +117,6 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-screen bg-[var(--light-background)]">
|
<div class="flex h-screen bg-[var(--light-background)]">
|
||||||
<!-- Loading overlay -->
|
|
||||||
{#if $isPending}
|
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
|
|
||||||
<div class="rounded-lg bg-[var(--light-background)] p-6 shadow-lg">
|
|
||||||
<p class="text-center">Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Error notification -->
|
<!-- Error notification -->
|
||||||
{#if $cError}
|
{#if $cError}
|
||||||
<div class="fixed right-4 top-4 z-50 max-w-md">
|
<div class="fixed right-4 top-4 z-50 max-w-md">
|
||||||
@ -149,23 +148,34 @@
|
|||||||
<header class="main-header">
|
<header class="main-header">
|
||||||
<button
|
<button
|
||||||
on:click={toggleSidebar}
|
on:click={toggleSidebar}
|
||||||
class="btn-secondary rounded-md p-2"
|
class="btn-secondary rounded-full p-2"
|
||||||
aria-label="Toggle sidebar"
|
aria-label="Toggle sidebar"
|
||||||
>
|
>
|
||||||
<ToggleSidebar />
|
<ToggleSidebar />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4 pl-2">
|
||||||
<!-- Content unchanged -->
|
{#if $currentFullNote}
|
||||||
|
<button on:click={toggleEditMode} class="btn-primary rounded-full">
|
||||||
|
{isEditing ? "Preview" : "Edit"}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-4 pl-2">
|
||||||
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Note content area -->
|
<!-- Note content area -->
|
||||||
<main class="main-content">
|
<main class="main-content">
|
||||||
{#if $currentFullNote}
|
{#if $currentFullNote}
|
||||||
<button on:click={toggleEditMode} class="btn-primary">
|
<NoteEditor note={$currentFullNote} {isEditing} {saveNote} />
|
||||||
{isEditing ? "Preview" : "Edit"}
|
{:else}
|
||||||
</button>
|
<div class="flex h-full flex-col items-center justify-center">
|
||||||
|
<p class="mb-4 text-lg">None selected</p>
|
||||||
|
<button on:click={createNewNote} class="btn-primary">Create note</button>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
@ -123,6 +123,8 @@
|
|||||||
|
|
||||||
<svelte:window on:keydown={handleKeydown} />
|
<svelte:window on:keydown={handleKeydown} />
|
||||||
|
|
||||||
|
<!-- TODO: add user details section (username, creation date, update date, admin status, etc.) -->
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="modal-backdrop"
|
class="modal-backdrop"
|
||||||
on:click={handleClickOutside}
|
on:click={handleClickOutside}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { NoteMetadataResponse, FullNoteResponse } from "$lib/client"
|
import type { NoteMetadata, FullNote } from "$lib/client"
|
||||||
import CreateNew from "$lib/icons/CreateNew.svelte"
|
import CreateNew from "$lib/icons/CreateNew.svelte"
|
||||||
import Logout from "$lib/icons/Logout.svelte"
|
import Logout from "$lib/icons/Logout.svelte"
|
||||||
import Search from "$lib/icons/Search.svelte"
|
import Search from "$lib/icons/Search.svelte"
|
||||||
@ -7,17 +7,27 @@
|
|||||||
|
|
||||||
// Props
|
// Props
|
||||||
export let sidebarOpen = true
|
export let sidebarOpen = true
|
||||||
export let notes: NoteMetadataResponse[] = []
|
export let notes: NoteMetadata[] = []
|
||||||
export let currentNote: FullNoteResponse | null = null
|
export let currentNote: FullNote | null = null
|
||||||
export let toggleSettings: () => void
|
export let toggleSettings: () => void
|
||||||
export let logout: () => Promise<void>
|
export let logout: () => Promise<void>
|
||||||
export let createNewNote: () => Promise<void>
|
export let createNewNote: () => Promise<void>
|
||||||
export let selectNote: (noteId: string) => Promise<void>
|
export let selectNote: (noteId: string) => Promise<void>
|
||||||
|
|
||||||
const formatDate = (dateString: string | Date): string => {
|
const formatDate = (dateString: string | Date): string => {
|
||||||
if (!dateString) return ""
|
if (!dateString) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
const d = new Date(dateString)
|
const d = new Date(dateString)
|
||||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
|
return d.toLocaleDateString(undefined, {
|
||||||
|
weekday: "short",
|
||||||
|
year: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric"
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleNoteKeydown = (event: KeyboardEvent, noteID: string) => {
|
const handleNoteKeydown = (event: KeyboardEvent, noteID: string) => {
|
||||||
@ -34,11 +44,13 @@
|
|||||||
: notes
|
: notes
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- TODO: make the sidebar take up whole screen width on mobile -->
|
||||||
|
<!-- TODO: add admin modal (opens a view similar to the settings modal) button to the bottom (if the user is an admin) -->
|
||||||
|
|
||||||
<aside class="sidebar" class:translate-x-0={sidebarOpen} class:translate-x-[-100%]={!sidebarOpen}>
|
<aside class="sidebar" class:translate-x-0={sidebarOpen} class:translate-x-[-100%]={!sidebarOpen}>
|
||||||
<!-- Sidebar header -->
|
<!-- Sidebar header -->
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-xl font-bold">Notes</h1>
|
|
||||||
<button
|
<button
|
||||||
on:click={createNewNote}
|
on:click={createNewNote}
|
||||||
class="btn-primary rounded-full p-2"
|
class="btn-primary rounded-full p-2"
|
||||||
@ -46,19 +58,14 @@
|
|||||||
>
|
>
|
||||||
<CreateNew />
|
<CreateNew />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search bar -->
|
<!-- Search bar -->
|
||||||
<div class="relative">
|
<div class="relative pl-4">
|
||||||
<input
|
<input type="text" placeholder="Search" bind:value={searchQuery} class="search-bar" />
|
||||||
type="text"
|
|
||||||
placeholder="Search notes..."
|
|
||||||
bind:value={searchQuery}
|
|
||||||
class="w-full rounded-md py-2 pl-8 pr-2"
|
|
||||||
/>
|
|
||||||
<Search />
|
<Search />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Notes list -->
|
<!-- Notes list -->
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div class="flex-1 overflow-y-auto">
|
||||||
@ -75,8 +82,8 @@
|
|||||||
aria-selected={currentNote && note.id === currentNote.id}
|
aria-selected={currentNote && note.id === currentNote.id}
|
||||||
>
|
>
|
||||||
<h3 class="truncate font-bold">{note.title || "Untitled Note"}</h3>
|
<h3 class="truncate font-bold">{note.title || "Untitled Note"}</h3>
|
||||||
<p class="sidebar-item-text mt-1">
|
<p class="sidebar-item-text mt-1 italic">
|
||||||
Last updated: {formatDate(note.updatedAt)}
|
{formatDate(note.updatedAt)}
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
@ -93,13 +100,13 @@
|
|||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<button
|
<button
|
||||||
on:click={toggleSettings}
|
on:click={toggleSettings}
|
||||||
class="btn-secondary rounded-md p-2"
|
class="btn-secondary rounded-full p-2"
|
||||||
aria-label="Toggle settings"
|
aria-label="Toggle settings"
|
||||||
>
|
>
|
||||||
<Settings />
|
<Settings />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button on:click={logout} class="btn-secondary rounded-md p-2" aria-label="Logout">
|
<button on:click={logout} class="btn-secondary rounded-full p-2" aria-label="Logout">
|
||||||
<Logout />
|
<Logout />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="general-sidebar-icon"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
<path
|
<path
|
||||||
d="M12.9999 2C10.2385 2 7.99991 4.23858 7.99991 7C7.99991 7.55228 8.44762 8 8.99991 8C9.55219 8 9.99991 7.55228 9.99991 7C9.99991 5.34315 11.3431 4 12.9999 4H16.9999C18.6568 4 19.9999 5.34315 19.9999 7V17C19.9999 18.6569 18.6568 20 16.9999 20H12.9999C11.3431 20 9.99991 18.6569 9.99991 17C9.99991 16.4477 9.55219 16 8.99991 16C8.44762 16 7.99991 16.4477 7.99991 17C7.99991 19.7614 10.2385 22 12.9999 22H16.9999C19.7613 22 21.9999 19.7614 21.9999 17V7C21.9999 4.23858 19.7613 2 16.9999 2H12.9999Z"
|
d="M12 3V12M18.3611 5.64001C19.6195 6.8988 20.4764 8.50246 20.8234 10.2482C21.1704 11.994 20.992 13.8034 20.3107 15.4478C19.6295 17.0921 18.4759 18.4976 16.9959 19.4864C15.5159 20.4752 13.776 21.0029 11.9961 21.0029C10.2162 21.0029 8.47625 20.4752 6.99627 19.4864C5.51629 18.4976 4.36274 17.0921 3.68146 15.4478C3.00019 13.8034 2.82179 11.994 3.16882 10.2482C3.51584 8.50246 4.37272 6.8988 5.6311 5.64001"
|
||||||
fill="#000000"
|
stroke-width="2"
|
||||||
/>
|
stroke-linecap="round"
|
||||||
<path
|
stroke-linejoin="round"
|
||||||
d="M13.9999 11C14.5522 11 14.9999 11.4477 14.9999 12C14.9999 12.5523 14.5522 13 13.9999 13V11Z"
|
|
||||||
fill="#000000"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M5.71783 11C5.80685 10.8902 5.89214 10.7837 5.97282 10.682C6.21831 10.3723 6.42615 10.1004 6.57291 9.90549C6.64636 9.80795 6.70468 9.72946 6.74495 9.67492L6.79152 9.61162L6.804 9.59454L6.80842 9.58848C6.80846 9.58842 6.80892 9.58778 5.99991 9L6.80842 9.58848C7.13304 9.14167 7.0345 8.51561 6.58769 8.19098C6.14091 7.86637 5.51558 7.9654 5.19094 8.41215L5.18812 8.41602L5.17788 8.43002L5.13612 8.48679C5.09918 8.53682 5.04456 8.61033 4.97516 8.7025C4.83623 8.88702 4.63874 9.14542 4.40567 9.43937C3.93443 10.0337 3.33759 10.7481 2.7928 11.2929L2.08569 12L2.7928 12.7071C3.33759 13.2519 3.93443 13.9663 4.40567 14.5606C4.63874 14.8546 4.83623 15.113 4.97516 15.2975C5.04456 15.3897 5.09918 15.4632 5.13612 15.5132L5.17788 15.57L5.18812 15.584L5.19045 15.5872C5.51509 16.0339 6.14091 16.1336 6.58769 15.809C7.0345 15.4844 7.13355 14.859 6.80892 14.4122L5.99991 15C6.80892 14.4122 6.80897 14.4123 6.80892 14.4122L6.804 14.4055L6.79152 14.3884L6.74495 14.3251C6.70468 14.2705 6.64636 14.1921 6.57291 14.0945C6.42615 13.8996 6.21831 13.6277 5.97282 13.318C5.89214 13.2163 5.80685 13.1098 5.71783 13H13.9999V11H5.71783Z"
|
|
||||||
fill="#000000"
|
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 626 B |
@ -1,3 +1,3 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 184 B After Width: | Height: | Size: 184 B |
@ -1,6 +1,6 @@
|
|||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="text-[var(--light-text)]/60 absolute left-2 top-3 h-4 w-4"
|
class="search-bar-icon"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
|
Before Width: | Height: | Size: 305 B After Width: | Height: | Size: 263 B |
@ -1,6 +1,20 @@
|
|||||||
<svg viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
<svg
|
||||||
><path
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
d="M772.672 575.808V448.192l70.848-70.848a370.688 370.688 0 0 0-56.512-97.664l-96.64 25.92-110.528-63.808-25.92-96.768a374.72 374.72 0 0 0-112.832 0l-25.92 96.768-110.528 63.808-96.64-25.92c-23.68 29.44-42.816 62.4-56.576 97.664l70.848 70.848v127.616l-70.848 70.848c13.76 35.264 32.832 68.16 56.576 97.664l96.64-25.92 110.528 63.808 25.92 96.768a374.72 374.72 0 0 0 112.832 0l25.92-96.768 110.528-63.808 96.64 25.92c23.68-29.44 42.816-62.4 56.512-97.664l-70.848-70.848z m39.744 254.848l-111.232-29.824-55.424 32-29.824 111.36c-37.76 10.24-77.44 15.808-118.4 15.808-41.024 0-80.768-5.504-118.464-15.808l-29.888-111.36-55.424-32-111.168 29.824A447.552 447.552 0 0 1 64 625.472L145.472 544v-64L64 398.528A447.552 447.552 0 0 1 182.592 193.28l111.168 29.824 55.424-32 29.888-111.36A448.512 448.512 0 0 1 497.472 64c41.024 0 80.768 5.504 118.464 15.808l29.824 111.36 55.424 32 111.232-29.824c56.32 55.68 97.92 126.144 118.592 205.184L849.472 480v64l81.536 81.472a447.552 447.552 0 0 1-118.592 205.184zM497.536 627.2a115.2 115.2 0 1 0 0-230.4 115.2 115.2 0 0 0 0 230.4z m0 76.8a192 192 0 1 1 0-384 192 192 0 0 1 0 384z"
|
class="general-sidebar-icon"
|
||||||
fill="#000000"
|
fill="none"
|
||||||
/></svg
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
>
|
>
|
||||||
|
<path
|
||||||
|
d="M20.3499 8.92293L19.9837 8.7192C19.9269 8.68756 19.8989 8.67169 19.8714 8.65524C19.5983 8.49165 19.3682 8.26564 19.2002 7.99523C19.1833 7.96802 19.1674 7.93949 19.1348 7.8831C19.1023 7.82677 19.0858 7.79823 19.0706 7.76998C18.92 7.48866 18.8385 7.17515 18.8336 6.85606C18.8331 6.82398 18.8332 6.79121 18.8343 6.72604L18.8415 6.30078C18.8529 5.62025 18.8587 5.27894 18.763 4.97262C18.6781 4.70053 18.536 4.44993 18.3462 4.23725C18.1317 3.99685 17.8347 3.82534 17.2402 3.48276L16.7464 3.1982C16.1536 2.85658 15.8571 2.68571 15.5423 2.62057C15.2639 2.56294 14.9765 2.56561 14.6991 2.62789C14.3859 2.69819 14.0931 2.87351 13.5079 3.22396L13.5045 3.22555L13.1507 3.43741C13.0948 3.47091 13.0665 3.48779 13.0384 3.50338C12.7601 3.6581 12.4495 3.74365 12.1312 3.75387C12.0992 3.7549 12.0665 3.7549 12.0013 3.7549C11.9365 3.7549 11.9024 3.7549 11.8704 3.75387C11.5515 3.74361 11.2402 3.65759 10.9615 3.50224C10.9334 3.48658 10.9056 3.46956 10.8496 3.4359L10.4935 3.22213C9.90422 2.86836 9.60915 2.69121 9.29427 2.62057C9.0157 2.55807 8.72737 2.55634 8.44791 2.61471C8.13236 2.68062 7.83577 2.85276 7.24258 3.19703L7.23994 3.1982L6.75228 3.48124L6.74688 3.48454C6.15904 3.82572 5.86441 3.99672 5.6517 4.23614C5.46294 4.4486 5.32185 4.69881 5.2374 4.97018C5.14194 5.27691 5.14703 5.61896 5.15853 6.3027L5.16568 6.72736C5.16676 6.79166 5.16864 6.82362 5.16817 6.85525C5.16343 7.17499 5.08086 7.48914 4.92974 7.77096C4.9148 7.79883 4.8987 7.8267 4.86654 7.88237C4.83436 7.93809 4.81877 7.96579 4.80209 7.99268C4.63336 8.26452 4.40214 8.49186 4.12733 8.65572C4.10015 8.67193 4.0715 8.68752 4.01521 8.71871L3.65365 8.91908C3.05208 9.25245 2.75137 9.41928 2.53256 9.65669C2.33898 9.86672 2.19275 10.1158 2.10349 10.3872C2.00259 10.6939 2.00267 11.0378 2.00424 11.7255L2.00551 12.2877C2.00706 12.9708 2.00919 13.3122 2.11032 13.6168C2.19979 13.8863 2.34495 14.134 2.53744 14.3427C2.75502 14.5787 3.05274 14.7445 3.64974 15.0766L4.00808 15.276C4.06907 15.3099 4.09976 15.3266 4.12917 15.3444C4.40148 15.5083 4.63089 15.735 4.79818 16.0053C4.81625 16.0345 4.8336 16.0648 4.8683 16.1255C4.90256 16.1853 4.92009 16.2152 4.93594 16.2452C5.08261 16.5229 5.16114 16.8315 5.16649 17.1455C5.16707 17.1794 5.16658 17.2137 5.16541 17.2827L5.15853 17.6902C5.14695 18.3763 5.1419 18.7197 5.23792 19.0273C5.32287 19.2994 5.46484 19.55 5.65463 19.7627C5.86915 20.0031 6.16655 20.1745 6.76107 20.5171L7.25478 20.8015C7.84763 21.1432 8.14395 21.3138 8.45869 21.379C8.73714 21.4366 9.02464 21.4344 9.30209 21.3721C9.61567 21.3017 9.90948 21.1258 10.4964 20.7743L10.8502 20.5625C10.9062 20.5289 10.9346 20.5121 10.9626 20.4965C11.2409 20.3418 11.5512 20.2558 11.8695 20.2456C11.9015 20.2446 11.9342 20.2446 11.9994 20.2446C12.0648 20.2446 12.0974 20.2446 12.1295 20.2456C12.4484 20.2559 12.7607 20.3422 13.0394 20.4975C13.0639 20.5112 13.0885 20.526 13.1316 20.5519L13.5078 20.7777C14.0971 21.1315 14.3916 21.3081 14.7065 21.3788C14.985 21.4413 15.2736 21.4438 15.5531 21.3855C15.8685 21.3196 16.1657 21.1471 16.7586 20.803L17.2536 20.5157C17.8418 20.1743 18.1367 20.0031 18.3495 19.7636C18.5383 19.5512 18.6796 19.3011 18.764 19.0297C18.8588 18.7252 18.8531 18.3858 18.8417 17.7119L18.8343 17.2724C18.8332 17.2081 18.8331 17.1761 18.8336 17.1445C18.8383 16.8247 18.9195 16.5104 19.0706 16.2286C19.0856 16.2007 19.1018 16.1726 19.1338 16.1171C19.166 16.0615 19.1827 16.0337 19.1994 16.0068C19.3681 15.7349 19.5995 15.5074 19.8744 15.3435C19.9012 15.3275 19.9289 15.3122 19.9838 15.2818L19.9857 15.2809L20.3472 15.0805C20.9488 14.7472 21.2501 14.5801 21.4689 14.3427C21.6625 14.1327 21.8085 13.8839 21.8978 13.6126C21.9981 13.3077 21.9973 12.9658 21.9958 12.2861L21.9945 11.7119C21.9929 11.0287 21.9921 10.6874 21.891 10.3828C21.8015 10.1133 21.6555 9.86561 21.463 9.65685C21.2457 9.42111 20.9475 9.25526 20.3517 8.92378L20.3499 8.92293Z"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M8.00033 12C8.00033 14.2091 9.79119 16 12.0003 16C14.2095 16 16.0003 14.2091 16.0003 12C16.0003 9.79082 14.2095 7.99996 12.0003 7.99996C9.79119 7.99996 8.00033 9.79082 8.00033 12Z"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 4.2 KiB |
@ -1,4 +1,4 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 20 20" fill="currentColor">
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
||||||
|
Before Width: | Height: | Size: 666 B After Width: | Height: | Size: 666 B |
@ -36,7 +36,9 @@ const calculateEntropy = (password: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Empty password exception
|
// Empty password exception
|
||||||
if (poolSize === 0) return 0
|
if (poolSize === 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
const uniqueChars = new Set(password.split("")).size
|
const uniqueChars = new Set(password.split("")).size
|
||||||
|
|
||||||
|
@ -1,12 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ThemeToggle from "$lib/components/ThemeToggle.svelte"
|
|
||||||
import "../app.css"
|
import "../app.css"
|
||||||
|
|
||||||
let { children } = $props()
|
let { children } = $props()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|
||||||
<div class="absolute right-4 top-4">
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
// -> Prevents component flickering during auth checks
|
// -> Prevents component flickering during auth checks
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}, 300)
|
}, 500)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|