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"
+ >
+
+ {#each state.items as item}
+ {#if 'is_separator' in item}
+ -
+
+
+ {:else}
+ -
+
+
+ {/if}
+ {/each}
+
+
+{/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)}
+ - {
+ 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}
+
+ {:else}
+ {query.name}
+ {/if}
+ {dayjs(query.createdAt).fromNow()}
+
+
+ {/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);
+ }}
+/>
+