Push Preview Gong Notes

This commit is contained in:
Andrea
2026-03-20 17:33:09 +01:00
parent d5a2e490f0
commit 91ba13aef6
32 changed files with 925 additions and 206 deletions

9
.prettierignore Normal file
View File

@@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/

15
.prettierrc Normal file
View File

@@ -0,0 +1,15 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

View File

@@ -1,3 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
"recommendations": ["svelte.svelte-vscode", "esbenp.prettier-vscode"]
}

View File

@@ -1 +1 @@
- Non modificare mai i file direttamente, proponi la soluzione nella chat.
- Puoi modificare direttamente i tag e lo stile di componenti e pagine.

13
package-lock.json generated
View File

@@ -21,6 +21,8 @@
"better-auth": "~1.4.21",
"drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"typescript": "^5.9.3",
@@ -4658,6 +4660,17 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/prettier-plugin-svelte": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.5.1.tgz",
"integrity": "sha512-65+fr5+cgIKWKiqM1Doum4uX6bY8iFCdztvvp2RcF+AJoieaw9kJOFMNcJo/bkmKYsxFaM9OsVZK/gWauG/5mg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"prettier": "^3.0.0",
"svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
}
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",

View File

@@ -14,7 +14,9 @@
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"auth:schema": "better-auth generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes"
"auth:schema": "better-auth generate --config src/lib/server/auth.ts --output src/lib/server/db/auth.schema.ts --yes",
"lint": "prettier --check .",
"format": "prettier --write ."
},
"devDependencies": {
"@better-auth/cli": "~1.4.21",
@@ -26,6 +28,8 @@
"better-auth": "~1.4.21",
"drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"typescript": "^5.9.3",

5
src/app.d.ts vendored
View File

@@ -4,7 +4,10 @@ import type { User, Session } from 'better-auth/minimal';
// for information about these interfaces
declare global {
namespace App {
interface Locals { user?: User; session?: Session }
interface Locals {
user?: User;
session?: Session;
}
// interface Error {}
// interface PageData {}

View File

@@ -3,6 +3,12 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View File

@@ -4,8 +4,6 @@ import { auth } from '$lib/server/auth';
import { svelteKitHandler } from 'better-auth/svelte-kit';
import { sequence } from '@sveltejs/kit/hooks';
const handleBetterAuth: Handle = async ({ event, resolve }) => {
const session = await auth.api.getSession({ headers: event.request.headers });
@@ -29,6 +27,6 @@ const handleRouting: Handle = async ({ event, resolve }) => {
}
return resolve(event);
}
};
export const handle: Handle = sequence(handleBetterAuth, handleRouting);

View File

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12.022,23a11.053,11.053,0,0,0,10.921-9.5,5.853,5.853,0,0,0-.577-3.5c-1.655-3.146-4.777-2.671-7.056-2.322-1.18.178-2.4.366-2.865-.035A2.416,2.416,0,0,1,12.02,6c0-2.683,0-5-3-5C3.753,1,1,6.534,1,12A11.023,11.023,0,0,0,12.022,23ZM9.016,3c.909,0,1,0,1,3a3.941,3.941,0,0,0,1.122,3.168c1.163,1,2.844.741,4.469.494,2.483-.379,4.061-.482,4.986,1.276a3.844,3.844,0,0,1,.363,2.293A9.024,9.024,0,0,1,3,12C3,8.382,4.6,3,9.016,3ZM5,7.5A1.5,1.5,0,1,1,6.5,9,1.5,1.5,0,0,1,5,7.5ZM4,12a1.5,1.5,0,1,1,1.5,1.5A1.5,1.5,0,0,1,4,12Zm3.5,3A1.5,1.5,0,1,1,6,16.5,1.5,1.5,0,0,1,7.5,15Zm8,3A3.5,3.5,0,1,0,12,14.5,3.5,3.5,0,0,0,15.5,18Zm0-5A1.5,1.5,0,1,1,14,14.5,1.5,1.5,0,0,1,15.5,13Z"/></svg>

After

Width:  |  Height:  |  Size: 899 B

View File

@@ -1,7 +0,0 @@
<script lang="ts">
</script>
<style>
</style>

View File

@@ -1,34 +0,0 @@
<script lang="ts">
import type { FileAttachment, ImageAttachment, Note } from "$lib/editor";
import { nanoid } from "nanoid";
import { onMount } from "svelte";
import imageSvg from "$lib/assets/composer/image.svg"
import fileSvg from "$lib/assets/composer/file.svg"
type Props = {
note?: Note;
onSubmit: (note: Note) => void;
}
let { note, onSubmit } : Props = $props();
const palette = ["#ffffff", "#fef3c7", "#dbeafe", "#dcfce7", "#fee2e2", "#f3e8ff"];
onMount(() => {
if (!note) {
note = {
id: nanoid(),
title: "",
content: "",
color: palette[0],
date: new Date(),
images: [],
files: []
}
}
return () => {
}
})
</script>

View File

@@ -1,31 +0,0 @@
<script lang="ts">
import type { Note } from "$lib/editor";
import NoteEditor from "./NoteEditor.svelte";
let expanded = $state(false);
const onCreate = (note: Note) => {
console.log("create note")
}
</script>
<svelte:window
onkeydown={(e) => {
if (e.key === "n" && e.metaKey) {
expanded = true;
return;
}
if( e.key === "Escape") {
expanded = false;
return;
}
}}
/>
{#if !expanded}
<div>Scrivi una nota...</div>
{:else}
<div class="note-editor-container">
<NoteEditor note={undefined} onSubmit={onCreate} />
</div>
{/if}

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import NoteCard from '$lib/components/board/Note.svelte';
import type { BoardNote } from '$lib/components/board/script/types';
import { BOARD_GRID_SIZE, BOARD_HEIGHT, BOARD_WIDTH } from '$lib/components/board/script/constants';
type Props = {
notes: BoardNote[];
onStartPan: (event: PointerEvent) => void;
onStartDrag: (event: PointerEvent, id: string) => void;
};
let { notes, onStartPan, onStartDrag }: Props = $props();
</script>
<section
class="board"
role="application"
aria-label="Bacheca note"
onpointerdown={onStartPan}
style={`--board-width:${BOARD_WIDTH}px; --board-height:${BOARD_HEIGHT}px; --grid-size:${BOARD_GRID_SIZE}px;`}
>
{#each notes as note (note.id)}
<div
class="note-item"
style={`left:${note.x}px; top:${note.y}px; z-index:${note.z};`}
role="button"
tabindex="0"
onpointerdown={(event) => {
event.stopPropagation();
onStartDrag(event, note.id);
}}
>
<NoteCard {note} />
</div>
{/each}
</section>
<style>
.board {
position: relative;
width: var(--board-width);
height: var(--board-height);
padding: 90px 30px 60px;
background-image:
linear-gradient(to right, rgba(140, 155, 176, 0.14) 1px, transparent 1px),
linear-gradient(to bottom, rgba(140, 155, 176, 0.14) 1px, transparent 1px);
background-size: var(--grid-size) var(--grid-size);
}
.note-item {
position: absolute;
width: 280px;
cursor: grab;
user-select: none;
touch-action: none;
}
.note-item:active {
cursor: grabbing;
}
</style>

View File

@@ -0,0 +1,65 @@
<script lang="ts">
import type { Note } from '$lib/components/editor/script/editor';
type Props = {
note: Note;
};
let { note }: Props = $props();
</script>
<article class="note-card" style={`background:${note.color};`}>
{#if note.title.trim().length > 0}
<h3 class="note-title">{note.title}</h3>
{/if}
{#if note.content.trim().length > 0}
<p class="note-content">{note.content}</p>
{:else}
<p class="note-content empty">Nota senza contenuto</p>
{/if}
<div class="note-date">{note.date.toLocaleString('it-IT')}</div>
</article>
<style>
.note-card {
width: 280px;
min-height: 140px;
border: 1px solid #dadce0;
border-radius: 14px;
box-shadow: 0 1px 2px rgba(60, 64, 67, 0.2), 0 2px 6px rgba(60, 64, 67, 0.18);
padding: 14px 14px 12px;
color: #202124;
display: flex;
flex-direction: column;
gap: 10px;
}
.note-title {
margin: 0;
font-size: 1.6rem;
font-weight: 600;
line-height: 1.25;
word-break: break-word;
}
.note-content {
margin: 0;
font-size: 1.42rem;
line-height: 1.35;
white-space: pre-wrap;
word-break: break-word;
}
.note-content.empty {
color: #5f6368;
font-style: italic;
}
.note-date {
margin-top: auto;
font-size: 1.2rem;
color: #5f6368;
}
</style>

View File

@@ -0,0 +1,9 @@
export const BOARD_WIDTH = 5400;
export const BOARD_HEIGHT = 5400;
export const BOARD_GRID_SIZE = 16;
export const NOTE_CARD_WIDTH = 280;
export const NOTE_CARD_MIN_HEIGHT = 140;
export const MINIMAP_WIDTH = 220;

View File

@@ -0,0 +1,31 @@
import type { Note } from '$lib/components/editor/script/editor';
export type BoardNote = Note & {
x: number;
y: number;
z: number;
};
export type DragState =
| {
id: string;
offsetX: number;
offsetY: number;
}
| null;
export type PanState =
| {
startX: number;
startY: number;
scrollLeft: number;
scrollTop: number;
}
| null;
export type ViewportState = {
scrollLeft: number;
scrollTop: number;
width: number;
height: number;
};

View File

@@ -0,0 +1,18 @@
import {
BOARD_HEIGHT,
BOARD_WIDTH,
NOTE_CARD_MIN_HEIGHT,
NOTE_CARD_WIDTH
} from '$lib/components/board/script/constants';
export const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
export const getRandomPosition = (idx: number) => ({
x: 200 + ((idx * 420) % (BOARD_WIDTH - NOTE_CARD_WIDTH - 260)),
y: 140 + Math.floor(idx / 8) * 260
});
export const clampNoteX = (x: number) => clamp(x, 0, BOARD_WIDTH - NOTE_CARD_WIDTH);
export const clampNoteY = (y: number) => clamp(y, 0, BOARD_HEIGHT - NOTE_CARD_MIN_HEIGHT);

View File

@@ -0,0 +1,193 @@
<script lang="ts">
import type { FileAttachment, ImageAttachment, Note } from '$lib/components/editor/script/editor';
import { nanoid } from 'nanoid';
import { onMount } from 'svelte';
import imageSvg from '$lib/assets/composer/image.svg';
import fileSvg from '$lib/assets/composer/file.svg';
import paletteSvg from '$lib/assets/composer/palette.svg';
type Props = {
note?: Note;
onSubmit: (note: Note) => void;
onClose?: () => void;
};
let { note, onSubmit, onClose }: Props = $props();
const palette = ['#ffffff', '#fef3c7', '#dbeafe', '#dcfce7', '#fee2e2', '#f3e8ff'];
onMount(() => {
if (!note) {
note = {
id: nanoid(),
title: '',
content: '',
color: palette[0],
date: new Date(),
images: [],
files: []
};
}
return () => {};
});
const handleClose = () => {
if (note) {
const hasContent = note.title.trim().length > 0 || note.content.trim().length > 0;
if (hasContent) {
onSubmit({ ...note, date: new Date() });
}
}
onClose?.();
};
</script>
<svelte:window />
{#if note}
<section class="note-editor" style={`background: ${note.color};`}>
<input
class="title-input"
type="text"
bind:value={note.title}
placeholder="Titolo"
aria-label="Titolo"
/>
<textarea
class="content-input"
bind:value={note.content}
placeholder="Scrivi una nota..."
aria-label="Contenuto nota"
></textarea>
<footer class="editor-footer">
<div class="left-actions" aria-label="Azioni nota">
<button type="button" class="icon-btn" aria-label="Sfondo colore">
<img src={paletteSvg} alt="" class="icon-svg" />
</button>
<button type="button" class="icon-btn" aria-label="Immagine">
<img src={imageSvg} alt="" class="icon-svg" />
</button>
<button type="button" class="icon-btn" aria-label="File">
<img src={fileSvg} alt="" class="icon-svg" />
</button>
</div>
<div class="right-actions">
<button type="button" class="close-btn" onclick={handleClose}>Chiudi</button>
</div>
</footer>
</section>
{/if}
<style>
.note-editor {
position: relative;
width: 100%;
border: 1px solid #dadce0;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(60, 64, 67, 0.3), 0 4px 8px rgba(60, 64, 67, 0.15);
padding: 10px 14px 8px;
}
.title-input,
.content-input {
width: 100%;
border: 0;
background: transparent;
color: #202124;
font-family: "Segoe UI", sans-serif;
}
.title-input:focus,
.content-input:focus {
outline: none;
}
.title-input {
min-height: 36px;
padding-right: 34px;
font-size: 1.8rem;
font-weight: 500;
}
.title-input::placeholder {
color: #3c4043;
opacity: 1;
}
.content-input {
min-height: 110px;
resize: none;
padding: 2px 0 8px;
font-size: 1.7rem;
line-height: 1.35;
}
.content-input::placeholder {
color: #5f6368;
opacity: 1;
}
.editor-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 4px;
}
.left-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 2px;
}
.icon-btn {
width: 30px;
height: 30px;
border: 0;
border-radius: 999px;
background: transparent;
color: #5f6368;
display: grid;
place-items: center;
cursor: pointer;
}
.icon-svg {
width: 18px;
height: 18px;
display: block;
object-fit: contain;
pointer-events: none;
}
.icon-btn:hover {
background: #f1f3f4;
}
.right-actions {
display: flex;
align-items: center;
gap: 6px;
}
.close-btn {
height: 32px;
border: 0;
border-radius: 6px;
background: transparent;
color: #202124;
font-size: 1.45rem;
font-weight: 500;
padding: 0 10px;
cursor: pointer;
}
.close-btn:hover {
background: #f1f3f4;
}
</style>

View File

@@ -0,0 +1,23 @@
export type Note = {
id: string;
title: string;
content: string;
color: string;
date: Date;
images: ImageAttachment[];
files: FileAttachment[];
};
export type ImageAttachment = NoteAttachment & { kind: 'image' };
export type FileAttachment = NoteAttachment & { kind: 'file' };
interface NoteAttachment {
id: string;
name: string;
size: number;
mimeType: string;
previewUrl?: string;
}

View File

@@ -0,0 +1,112 @@
<script lang="ts">
import { BOARD_HEIGHT, BOARD_WIDTH, MINIMAP_WIDTH } from '$lib/components/board/script/constants';
import type { BoardNote, ViewportState } from '$lib/components/board/script/types';
import { clamp } from '$lib/components/board/script/utils';
type Props = {
notes: BoardNote[];
viewport: ViewportState;
onNavigate: (scrollLeft: number, scrollTop: number) => void;
};
let { notes, viewport, onNavigate }: Props = $props();
let dragging = $state(false);
const minimapHeight = (MINIMAP_WIDTH * BOARD_HEIGHT) / BOARD_WIDTH;
const viewportLeftPercent = $derived((viewport.scrollLeft / BOARD_WIDTH) * 100);
const viewportTopPercent = $derived((viewport.scrollTop / BOARD_HEIGHT) * 100);
const viewportWidthPercent = $derived((viewport.width / BOARD_WIDTH) * 100);
const viewportHeightPercent = $derived((viewport.height / BOARD_HEIGHT) * 100);
const navigateFromPointer = (event: PointerEvent) => {
const element = event.currentTarget as HTMLElement;
const rect = element.getBoundingClientRect();
const relX = clamp(event.clientX - rect.left, 0, rect.width);
const relY = clamp(event.clientY - rect.top, 0, rect.height);
const nextLeft = (relX / rect.width) * BOARD_WIDTH - viewport.width / 2;
const nextTop = (relY / rect.height) * BOARD_HEIGHT - viewport.height / 2;
onNavigate(nextLeft, nextTop);
};
</script>
<aside class="minimap-shell" aria-label="Minimappa bacheca">
<div
class="minimap"
role="application"
aria-label="Minimappa interattiva"
style={`--minimap-width:${MINIMAP_WIDTH}px; --minimap-height:${minimapHeight}px;`}
onpointerdown={(event) => {
dragging = true;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
navigateFromPointer(event);
}}
onpointermove={(event) => {
if (!dragging) return;
navigateFromPointer(event);
}}
onpointerup={() => (dragging = false)}
onpointercancel={() => (dragging = false)}
>
{#each notes as note (note.id)}
<span
class="note-dot"
style={`left:${(note.x / BOARD_WIDTH) * 100}%; top:${(note.y / BOARD_HEIGHT) * 100}%;`}
></span>
{/each}
<div
class="viewport-frame"
style={`left:${viewportLeftPercent}%; top:${viewportTopPercent}%; width:${viewportWidthPercent}%; height:${viewportHeightPercent}%;`}
></div>
</div>
</aside>
<style>
.minimap-shell {
position: fixed;
left: 14px;
bottom: 14px;
z-index: 90;
padding: 6px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.68);
backdrop-filter: blur(5px);
box-shadow: 0 4px 14px rgba(32, 33, 36, 0.16);
}
.minimap {
position: relative;
width: var(--minimap-width);
height: var(--minimap-height);
border-radius: 8px;
overflow: hidden;
cursor: pointer;
background-image:
linear-gradient(to right, rgba(140, 155, 176, 0.14) 1px, transparent 1px),
linear-gradient(to bottom, rgba(140, 155, 176, 0.14) 1px, transparent 1px);
background-size: 10px 10px;
background-color: rgba(234, 239, 245, 0.92);
}
.note-dot {
position: absolute;
width: 5px;
height: 5px;
border-radius: 999px;
background: rgba(50, 84, 122, 0.76);
transform: translate(-50%, -50%);
}
.viewport-frame {
position: absolute;
border: 1px solid rgba(17, 80, 145, 0.9);
background: rgba(63, 134, 206, 0.12);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.75);
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import type { Note } from '$lib/editor';
import NoteEditor from '../editor/NoteEditor.svelte';
type Props = {
onCreate: (note: Note) => void;
};
let { onCreate }: Props = $props();
let expanded = $state(false);
</script>
<svelte:window
onkeydown={(e) => {
if (e.key === 'Escape') {
expanded = false;
return;
}
}}
/>
{#if !expanded}
<button class="note-navbar-trigger" type="button" onclick={() => (expanded = true)}>
Scrivi una nota...
</button>
{:else}
<div class="note-editor-container">
<NoteEditor note={undefined} onSubmit={onCreate} onClose={() => (expanded = false)} />
</div>
{/if}
<style>
.note-navbar-trigger {
width: 100%;
min-height: 52px;
border: 1px solid #d3d3d3;
border-radius: 10px;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
padding: 0 18px;
text-align: left;
font-size: 1.95rem;
line-height: 1;
color: #5f6368;
cursor: text;
transition:
box-shadow 0.15s ease,
border-color 0.15s ease;
}
.note-navbar-trigger:hover,
.note-navbar-trigger:focus-visible {
border-color: #c6c6c6;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
outline: none;
}
.note-editor-container {
width: 100%;
}
</style>

View File

@@ -1,25 +0,0 @@
export type Note = {
id: string;
title: string;
content: string;
color: string;
date: Date;
images: ImageAttachment[];
files: FileAttachment[];
};
export type ImageAttachment = NoteAttachment & { kind: "image" };
export type FileAttachment = NoteAttachment & { kind: "file" };
interface NoteAttachment {
id: string;
name: string;
size: number;
mimeType: string;
previewUrl?: string;
}

View File

@@ -1 +0,0 @@
export type MediaProvider = 'youtube' | 'youtube-music' | 'spotify';

View File

@@ -1,107 +1,105 @@
import { relations, sql } from "drizzle-orm";
import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core";
import { relations, sql } from 'drizzle-orm';
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';
export const user = sqliteTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: integer("email_verified", { mode: "boolean" })
.default(false)
.notNull(),
image: text("image"),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
export const user = sqliteTable('user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: integer('email_verified', { mode: 'boolean' }).default(false).notNull(),
image: text('image'),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull()
});
export const session = sqliteTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
token: text("token").notNull().unique(),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [index("session_userId_idx").on(table.userId)],
'session',
{
id: text('id').primaryKey(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
token: text('token').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' })
},
(table) => [index('session_userId_idx').on(table.userId)]
);
export const account = sqliteTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: integer("access_token_expires_at", {
mode: "timestamp_ms",
}),
refreshTokenExpiresAt: integer("refresh_token_expires_at", {
mode: "timestamp_ms",
}),
scope: text("scope"),
password: text("password"),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
'account',
{
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: integer('access_token_expires_at', {
mode: 'timestamp_ms'
}),
refreshTokenExpiresAt: integer('refresh_token_expires_at', {
mode: 'timestamp_ms'
}),
scope: text('scope'),
password: text('password'),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull()
},
(table) => [index('account_userId_idx').on(table.userId)]
);
export const verification = sqliteTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
'verification',
{
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull()
},
(table) => [index('verification_identifier_idx').on(table.identifier)]
);
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
sessions: many(session),
accounts: many(account)
}));
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
user: one(user, {
fields: [session.userId],
references: [user.id]
})
}));
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
user: one(user, {
fields: [account.userId],
references: [user.id]
})
}));

View File

@@ -1,34 +1,44 @@
import { integer, real, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { user } from './auth.schema';
export * from './auth.schema';
export * from './auth.schema';
export const notes = sqliteTable('notes', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
title: text('title').notNull(),
content: text('text').notNull(),
x: real("x").default(0).notNull(),
y: real("y").default(0).notNull(),
z: integer("z").default(1).notNull(),
x: real('x').default(0).notNull(),
y: real('y').default(0).notNull(),
z: integer('z').default(1).notNull(),
color: text("color").default("#ffffff").notNull(),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull().$defaultFn(() => new Date()),
color: text('color').default('#ffffff').notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date()),
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
});
export const noteFiles = sqliteTable('note_files', {
id: text('id').primaryKey(),
noteId: text('note_id').notNull().references(() => notes.id, { onDelete: 'cascade' }),
noteId: text('note_id')
.notNull()
.references(() => notes.id, { onDelete: 'cascade' }),
kind: text('kind', { enum: ['image', 'file'] }).notNull().default('file'),
kind: text('kind', { enum: ['image', 'file'] })
.notNull()
.default('file'),
position: integer('position').notNull().default(0),
name: text('name').notNull(),
size: integer('size').notNull(),
mimeType: text('mime_type').notNull(),
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull().$defaultFn(() => new Date())
createdAt: integer('created_at', { mode: 'timestamp_ms' })
.notNull()
.$defaultFn(() => new Date())
});

View File

@@ -50,7 +50,9 @@ export const imapAuth = () => ({
const adapter = ctx.context.internalAdapter;
const existingUser = await adapter.findUserByEmail(email, { includeAccounts: true });
const credentialAccount = existingUser?.accounts.find((account) => account.providerId === 'credential');
const credentialAccount = existingUser?.accounts.find(
(account) => account.providerId === 'credential'
);
const credentialPassword = credentialAccount?.password;
const isDbPasswordValid =
typeof credentialPassword === 'string' &&

View File

@@ -9,3 +9,9 @@
</svelte:head>
{@render children()}
<style>
:global(body) {
font-family: 'Plus Jakarta Sans', sans-serif;
}
</style>

View File

@@ -1,7 +1,183 @@
<script lang="ts">
import { onMount } from 'svelte';
import Navbar from '$lib/components/ui/Navbar.svelte';
import BoardCanvas from '$lib/components/board/BoardCanvas.svelte';
import Minimap from '$lib/components/ui/Minimap.svelte';
import type { Note } from '$lib/editor';
import { BOARD_HEIGHT, BOARD_WIDTH } from '$lib/board/constants';
import type { BoardNote, DragState, PanState, ViewportState } from '$lib/board/types';
import { clamp, clampNoteX, clampNoteY, getRandomPosition } from '$lib/board/utils';
let notes = $state<BoardNote[]>([]);
let drag = $state<DragState>(null);
let pan = $state<PanState>(null);
let maxZ = $state(1);
let viewport = $state<ViewportState>({
scrollLeft: 0,
scrollTop: 0,
width: 0,
height: 0
});
let viewportEl: HTMLDivElement | null = null;
const updateViewportMetrics = () => {
if (!viewportEl) return;
viewport = {
scrollLeft: viewportEl.scrollLeft,
scrollTop: viewportEl.scrollTop,
width: viewportEl.clientWidth,
height: viewportEl.clientHeight
};
};
onMount(() => {
updateViewportMetrics();
});
const handleCreate = (note: Note) => {
const pos = getRandomPosition(notes.length);
maxZ += 1;
notes = [...notes, { ...note, x: pos.x, y: pos.y, z: maxZ }];
};
const bringToFront = (id: string) => {
maxZ += 1;
notes = notes.map((note) => (note.id === id ? { ...note, z: maxZ } : note));
};
const startDrag = (event: PointerEvent, id: string) => {
const element = event.currentTarget as HTMLElement;
const rect = element.getBoundingClientRect();
drag = {
id,
offsetX: event.clientX - rect.left,
offsetY: event.clientY - rect.top
};
bringToFront(id);
element.setPointerCapture(event.pointerId);
};
const stopDrag = () => {
drag = null;
};
const moveDrag = (event: PointerEvent) => {
if (!drag || !viewportEl) return;
const viewportRect = viewportEl.getBoundingClientRect();
const pointerBoardX = viewportEl.scrollLeft + (event.clientX - viewportRect.left);
const pointerBoardY = viewportEl.scrollTop + (event.clientY - viewportRect.top);
const nextX = clampNoteX(pointerBoardX - drag.offsetX);
const nextY = clampNoteY(pointerBoardY - drag.offsetY);
notes = notes.map((note) => (note.id === drag?.id ? { ...note, x: nextX, y: nextY } : note));
};
const startPan = (event: PointerEvent) => {
if (!viewportEl || drag) return;
pan = {
startX: event.clientX,
startY: event.clientY,
scrollLeft: viewportEl.scrollLeft,
scrollTop: viewportEl.scrollTop
};
};
const movePan = (event: PointerEvent) => {
if (!pan || !viewportEl || drag) return;
const dx = event.clientX - pan.startX;
const dy = event.clientY - pan.startY;
viewportEl.scrollLeft = pan.scrollLeft - dx;
viewportEl.scrollTop = pan.scrollTop - dy;
updateViewportMetrics();
};
const stopPan = () => {
pan = null;
};
const navigateTo = (scrollLeft: number, scrollTop: number) => {
if (!viewportEl) return;
viewportEl.scrollLeft = clamp(scrollLeft, 0, BOARD_WIDTH - viewportEl.clientWidth);
viewportEl.scrollTop = clamp(scrollTop, 0, BOARD_HEIGHT - viewportEl.clientHeight);
updateViewportMetrics();
};
</script>
<svelte:window
onresize={updateViewportMetrics}
onpointermove={(event) => {
moveDrag(event);
movePan(event);
}}
onpointerup={() => {
stopDrag();
stopPan();
}}
onpointercancel={() => {
stopDrag();
stopPan();
}}
/>
<div class="board-viewport" bind:this={viewportEl} onscroll={updateViewportMetrics}>
<div class="top-composer-shell">
<div class="top-composer">
<Navbar onCreate={handleCreate} />
</div>
</div>
<BoardCanvas notes={notes} onStartPan={startPan} onStartDrag={startDrag} />
<Minimap notes={notes} {viewport} onNavigate={navigateTo} />
</div>
<style>
</style>
:global(body) {
margin: 0;
}
.board-viewport {
height: 100vh;
overflow: auto;
background:
radial-gradient(circle at top right, #f8fbff, #eef2f7 45%, #e6ebf2 100%);
scrollbar-width: none;
-ms-overflow-style: none;
cursor: grab;
}
.board-viewport:active {
cursor: grabbing;
}
.board-viewport::-webkit-scrollbar {
display: none;
}
.top-composer-shell {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 80;
padding: 16px 14px 0;
pointer-events: none;
background: transparent;
}
.top-composer {
max-width: 720px;
margin: 0 auto;
pointer-events: auto;
}
</style>

View File

@@ -6,7 +6,8 @@ const config = {
adapter: adapter()
},
vitePlugin: {
dynamicCompileOptions: ({ filename }) => filename.includes('node_modules') ? undefined : { runes: true }
dynamicCompileOptions: ({ filename }) =>
filename.includes('node_modules') ? undefined : { runes: true }
}
};