diff --git a/src/lib/components/ContextMenu/ContextMenu.svelte b/src/lib/components/ContextMenu/ContextMenu.svelte new file mode 100644 index 0000000..008da21 --- /dev/null +++ b/src/lib/components/ContextMenu/ContextMenu.svelte @@ -0,0 +1,111 @@ + + +{#if state.open} + +
{ + e.preventDefault(); + state.close(); + }} + >
+ +
e.preventDefault()} + role="menu" + tabindex="-1" + > + +
+{/if} + + diff --git a/src/lib/components/ContextMenu/ContextMenuState.svelte.ts b/src/lib/components/ContextMenu/ContextMenuState.svelte.ts new file mode 100644 index 0000000..785bce3 --- /dev/null +++ b/src/lib/components/ContextMenu/ContextMenuState.svelte.ts @@ -0,0 +1,32 @@ +import type { ContextMenuItem, ContextMenuOptions } from './types'; + +export class ContextMenuState { + #items = $state.raw([]); + #is_open = $derived(!!this.#items.length); + #position = $state.raw<{ x: number; y: number }>({ x: 0, y: 0 }); + + get items() { + return this.#items; + } + + get open() { + return this.#is_open; + } + + get position() { + return this.#position; + } + + constructor() { + this.close = this.close.bind(this); + } + + show(options: ContextMenuOptions) { + this.#items = options.items; + this.#position = options.position; + } + + close() { + this.#items = []; + } +} diff --git a/src/lib/components/ContextMenu/index.ts b/src/lib/components/ContextMenu/index.ts new file mode 100644 index 0000000..85681be --- /dev/null +++ b/src/lib/components/ContextMenu/index.ts @@ -0,0 +1,3 @@ +export { default as ContextMenu } from './ContextMenu.svelte'; +export { ContextMenuState } from './ContextMenuState.svelte'; +export type { ContextMenuOptions } from './types'; diff --git a/src/lib/components/ContextMenu/types.d.ts b/src/lib/components/ContextMenu/types.d.ts new file mode 100644 index 0000000..8fb243a --- /dev/null +++ b/src/lib/components/ContextMenu/types.d.ts @@ -0,0 +1,16 @@ +export type ContextMenuItem = ContextMenuAction | ContextMenuSeparator; + +export interface ContextMenuAction { + label: string; + disabled?: boolean; + onClick?: (e: MouseEvent) => MaybePromise; +} + +export interface ContextMenuSeparator { + is_separator: true; +} + +export interface ContextMenuOptions { + position: { x: number; y: number }; + items: ContextMenuItem[]; +} diff --git a/src/lib/components/History.svelte b/src/lib/components/History.svelte index 6017ce6..b2740c7 100644 --- a/src/lib/components/History.svelte +++ b/src/lib/components/History.svelte @@ -34,8 +34,8 @@ } }} > - {dayjs(entry.timestamp).fromNow()}
{entry.content}
+ {dayjs(entry.timestamp).fromNow()} {/each} @@ -63,13 +63,17 @@ user-select: none; -webkit-user-select: none; - &:focus { + &:is(:focus-within) { outline: none; - background-color: hsl(210deg 100% 52%); + background-color: hsl(0deg 0% 19% / 100%); } } .content { + height: 18px; + padding: 3px 0; + line-height: 1.15; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/src/lib/components/Modal.svelte b/src/lib/components/Modal.svelte index 8985065..256229c 100644 --- a/src/lib/components/Modal.svelte +++ b/src/lib/components/Modal.svelte @@ -57,8 +57,8 @@ max-block-size: min(80vh, 100%); margin-top: 0; overflow: hidden; - transition: opacity 0.5s; - animation: slide-out-up 0.5s cubic-bezier(0.25, 0, 0.3, 1) forwards; + transition: opacity 0.3s; + animation: slide-out-up 0.3s cubic-bezier(0.25, 0, 0.3, 1) forwards; filter: drop-shadow(3px 5px 10px hsla(0deg 0% 0% / 10%)); z-index: 2147483647; padding: 0; @@ -70,11 +70,11 @@ border-top-right-radius: 0; width: 100%; - max-width: 580px; + max-width: 420px; } dialog[open] { - animation: slide-in-down 0.5s cubic-bezier(0.25, 0, 0.3, 1) forwards; + animation: slide-in-down 0.3s cubic-bezier(0.25, 0, 0.3, 1) forwards; } dialog:not([open]) { diff --git a/src/lib/components/Queries/Queries.svelte b/src/lib/components/Queries/Queries.svelte new file mode 100644 index 0000000..f13c8eb --- /dev/null +++ b/src/lib/components/Queries/Queries.svelte @@ -0,0 +1,170 @@ + + +
    + {#each queries as query (query.id)} +
  1. { + e.preventDefault(); + context_menu.show({ + items: [ + { + label: 'Open', + onClick: () => onopen?.(query) + }, + { + label: 'Rename', + onClick: () => (editing_id = query.id) + }, + { is_separator: true }, + { + label: 'Delete', + onClick: () => ondelete?.(query) + } + ], + position: { x: e.clientX, y: e.clientY } + }); + }} + role="menuitem" + onkeydown={(e) => { + if (e.key === 'Enter' && !editing_id) { + e.preventDefault(); + editing_id = query.id; + } + }} + onclick={async (e) => { + if (e.detail >= 2) { + await onopen?.(query); + e.currentTarget.blur(); + } + }} + > +
    + {#if editing_id === query.id} +
    { + e.preventDefault(); + const form_data = new FormData(e.currentTarget); + const name = form_data.get('name') as string; + if (name && name !== query.name) { + await onrename?.({ ...query, name }); + } + editing_id = undefined; + }} + > + (editing_id = undefined)} + autocomplete="off" + /> +
    + {:else} + {query.name} + {/if} + {dayjs(query.createdAt).fromNow()} +
    +
  2. + {/each} +
+ + diff --git a/src/lib/components/Queries/SaveQueryModal.svelte b/src/lib/components/Queries/SaveQueryModal.svelte new file mode 100644 index 0000000..d880f70 --- /dev/null +++ b/src/lib/components/Queries/SaveQueryModal.svelte @@ -0,0 +1,115 @@ + + +{#if open} + (open = false)} bind:this={modal}> +
+ + +
+ + +
+
+
+{/if} + + diff --git a/src/lib/components/Queries/index.ts b/src/lib/components/Queries/index.ts new file mode 100644 index 0000000..b48fa77 --- /dev/null +++ b/src/lib/components/Queries/index.ts @@ -0,0 +1,2 @@ +export { default as Queries } from './Queries.svelte'; +export { default as SaveQueryModal } from './SaveQueryModal.svelte'; diff --git a/src/lib/components/SideBar.svelte b/src/lib/components/SideBar.svelte index 2f095dd..f126438 100644 --- a/src/lib/components/SideBar.svelte +++ b/src/lib/components/SideBar.svelte @@ -1,8 +1,10 @@
@@ -29,6 +45,9 @@ {#if tab === 'sources'} {/if} + {#if tab === 'queries'} + + {/if} {#if tab === 'history'} {/if} diff --git a/src/lib/database/index.ts b/src/lib/database/index.ts index 543c01a..14c04d0 100644 --- a/src/lib/database/index.ts +++ b/src/lib/database/index.ts @@ -1,3 +1,4 @@ +import { dev } from '$app/environment'; import { MIGRATIONS } from '$lib/migrations'; import { IndexedDBCache } from '@agnosticeng/cache'; import { MigrationManager } from '@agnosticeng/migrate'; @@ -41,3 +42,8 @@ class Database { export type { Database }; export const db = new Database(); + +if (dev) { + // @ts-ignore + window.db = db; +} diff --git a/src/lib/migrations/002_create_queries_table.sql b/src/lib/migrations/002_create_queries_table.sql new file mode 100644 index 0000000..47b0147 --- /dev/null +++ b/src/lib/migrations/002_create_queries_table.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS queries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + sql TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TRIGGER IF NOT EXISTS update_queries_updated_at +AFTER UPDATE ON queries +FOR EACH ROW +BEGIN + UPDATE queries + SET updated_at = datetime('now') + WHERE id = OLD.id; +END; diff --git a/src/lib/migrations/index.ts b/src/lib/migrations/index.ts index 555450d..cdbdb19 100644 --- a/src/lib/migrations/index.ts +++ b/src/lib/migrations/index.ts @@ -1,7 +1,9 @@ import type { Migration } from '@agnosticeng/migrate'; import CREATE_HISTORY_TABLE_SCRIPT from './001_create_history_table.sql?raw'; +import CREATE_QUERIES_TABLE_SCRIPT from './002_create_queries_table.sql?raw'; export const MIGRATIONS: Migration[] = [ - { name: 'create_history_table', script: CREATE_HISTORY_TABLE_SCRIPT } + { name: 'create_history_table', script: CREATE_HISTORY_TABLE_SCRIPT }, + { name: 'create_queries_table', script: CREATE_QUERIES_TABLE_SCRIPT } ]; diff --git a/src/lib/repositories/history.ts b/src/lib/repositories/history.ts index 6f88cca..79ee58a 100644 --- a/src/lib/repositories/history.ts +++ b/src/lib/repositories/history.ts @@ -1,4 +1,8 @@ import { db, type Database } from '$lib/database'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; + +dayjs.extend(utc); export interface HistoryEntry { id: number; @@ -7,19 +11,21 @@ export interface HistoryEntry { } export interface HistoryRepository { - get_all(): Promise; + getAll(): Promise; add(content: string): Promise; } class SQLiteHistoryRepository implements HistoryRepository { constructor(private db: Database) {} - async get_all(): Promise { + async getAll(): Promise { const rows = await this.db.exec('SELECT * FROM history ORDER BY timestamp DESC'); return rows.map((row) => ({ id: row.id as number, content: row.content as string, - timestamp: new Date(row.timestamp as string) + timestamp: dayjs(row.timestamp as string) + .utc(true) + .toDate() })); } @@ -33,7 +39,9 @@ class SQLiteHistoryRepository implements HistoryRepository { return { id: row.id as number, content: row.content as string, - timestamp: new Date(row.timestamp as string) + timestamp: dayjs(row.timestamp as string) + .utc(true) + .toDate() }; } } diff --git a/src/lib/repositories/queries.ts b/src/lib/repositories/queries.ts new file mode 100644 index 0000000..4ecf97a --- /dev/null +++ b/src/lib/repositories/queries.ts @@ -0,0 +1,79 @@ +import { db, type Database } from '$lib/database'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; + +dayjs.extend(utc); + +export interface Query { + id: number; + name: string; + sql: string; + + createdAt: Date; + updatedAt: Date; +} + +export interface QueryRepository { + getAll(): Promise; + getById(id: number): Promise; + create(name: string, sql: string): Promise; + update(query: Query): Promise; + delete(id: number): Promise; +} + +class SQLiteQueryRepository implements QueryRepository { + constructor(private db: Database) {} + + async getAll(): Promise { + const rows = await this.db.exec('SELECT * FROM queries'); + return rows.map((row) => row_to_query(row)); + } + + async getById(id: number): Promise { + const [row] = await this.db.exec('SELECT * FROM queries WHERE id = ?', [id]); + if (!row) throw Error('Query not found'); + return row_to_query(row); + } + + async create(name: string, sql: string): Promise { + const [row] = await this.db.exec('INSERT INTO queries (name, sql) VALUES (?, ?) RETURNING *', [ + name, + sql + ]); + + if (!row) throw Error('Failed to insert query'); + + return row_to_query(row); + } + + async update(query: Query): Promise { + const [row] = await this.db.exec( + 'UPDATE queries SET name = ?, sql = ? WHERE id = ? RETURNING *', + [query.name, query.sql, query.id] + ); + + if (!row) throw Error('Failed to update query'); + + return row_to_query(row); + } + + async delete(id: number): Promise { + await this.db.exec('DELETE FROM queries WHERE id = ?', [id]); + } +} + +function row_to_query(row: Awaited>[number]): Query { + return { + id: row.id as number, + name: row.name as string, + sql: row.sql as string, + createdAt: dayjs(row.created_at as string) + .utc(true) + .toDate(), + updatedAt: dayjs(row.updated_at as string) + .utc(true) + .toDate() + }; +} + +export const query_repository: QueryRepository = new SQLiteQueryRepository(db); diff --git a/src/lib/types.d.ts b/src/lib/types.d.ts index f1ac9a5..6422407 100644 --- a/src/lib/types.d.ts +++ b/src/lib/types.d.ts @@ -1,5 +1,5 @@ -type MaybePromise = T | Promise; +import type { ContextMenuState } from './components/ContextMenu'; export type AppContext = { - tables: Table[]; + context_menu: ContextMenuState; }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index ffa3006..9f1088d 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,12 +1,17 @@ + + + + {#snippet actions()} + {/snippet} @@ -61,7 +90,30 @@
{#snippet a()} - + { + await query_repository.delete(query.id); + const index = queries.indexOf(query); + queries = queries.slice(0, index).concat(queries.slice(index + 1)); + }} + onQueryOpen={(q) => { + query = q.sql; + }} + onQueryRename={async (q) => { + const updated = await query_repository.update(q); + const index = queries.findIndex((_q) => _q.id === updated.id); + if (index !== -1) { + queries = queries + .slice(0, index) + .concat(updated) + .concat(queries.slice(index + 1)); + } + }} + /> {/snippet} {#snippet b()} @@ -76,6 +128,14 @@
+ { + const q = await query_repository.create(name, query); + queries = queries.concat(q); + }} +/> +