Merge pull request #48 from agnosticeng/feat/save-queries
feat: save queries
This commit is contained in:
111
src/lib/components/ContextMenu/ContextMenu.svelte
Normal file
111
src/lib/components/ContextMenu/ContextMenu.svelte
Normal file
@@ -0,0 +1,111 @@
|
||||
<script lang="ts">
|
||||
import type { ContextMenuState } from './ContextMenuState.svelte';
|
||||
|
||||
interface Props {
|
||||
state: ContextMenuState;
|
||||
}
|
||||
|
||||
let { state }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if state.open}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="backdrop"
|
||||
onclick={state.close}
|
||||
oncontextmenu={(e) => {
|
||||
e.preventDefault();
|
||||
state.close();
|
||||
}}
|
||||
></div>
|
||||
|
||||
<div
|
||||
style:--x="{state.position.x}px"
|
||||
style:--y="{state.position.y}px"
|
||||
oncontextmenu={(e) => e.preventDefault()}
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
>
|
||||
<ul>
|
||||
{#each state.items as item}
|
||||
{#if 'is_separator' in item}
|
||||
<li>
|
||||
<hr />
|
||||
</li>
|
||||
{:else}
|
||||
<li>
|
||||
<button
|
||||
disabled={item.disabled}
|
||||
onclick={async (e) => {
|
||||
await item.onClick?.(e);
|
||||
state.close();
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
div.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9998;
|
||||
}
|
||||
|
||||
div[role='menu'] {
|
||||
position: fixed;
|
||||
left: var(--x);
|
||||
top: var(--y);
|
||||
z-index: 9999;
|
||||
min-width: 120px;
|
||||
|
||||
border: 1px solid hsl(0deg 0% 29%);
|
||||
border-radius: 6px;
|
||||
background-color: hsl(0deg 0% 18%);
|
||||
padding: 5px;
|
||||
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
appearance: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
|
||||
width: 100%;
|
||||
background-color: transparent;
|
||||
text-align: start;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
cursor: pointer;
|
||||
background-color: hsl(0deg 0% 29%);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: hsl(0deg 0% 50%);
|
||||
}
|
||||
}
|
||||
|
||||
li:has(> hr) {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid hsl(0deg 0% 29%);
|
||||
}
|
||||
</style>
|
||||
32
src/lib/components/ContextMenu/ContextMenuState.svelte.ts
Normal file
32
src/lib/components/ContextMenu/ContextMenuState.svelte.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { ContextMenuItem, ContextMenuOptions } from './types';
|
||||
|
||||
export class ContextMenuState {
|
||||
#items = $state.raw<ContextMenuItem[]>([]);
|
||||
#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 = [];
|
||||
}
|
||||
}
|
||||
3
src/lib/components/ContextMenu/index.ts
Normal file
3
src/lib/components/ContextMenu/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as ContextMenu } from './ContextMenu.svelte';
|
||||
export { ContextMenuState } from './ContextMenuState.svelte';
|
||||
export type { ContextMenuOptions } from './types';
|
||||
16
src/lib/components/ContextMenu/types.d.ts
vendored
Normal file
16
src/lib/components/ContextMenu/types.d.ts
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
export type ContextMenuItem = ContextMenuAction | ContextMenuSeparator;
|
||||
|
||||
export interface ContextMenuAction {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick?: (e: MouseEvent) => MaybePromise<any>;
|
||||
}
|
||||
|
||||
export interface ContextMenuSeparator {
|
||||
is_separator: true;
|
||||
}
|
||||
|
||||
export interface ContextMenuOptions {
|
||||
position: { x: number; y: number };
|
||||
items: ContextMenuItem[];
|
||||
}
|
||||
@@ -34,8 +34,8 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="time">{dayjs(entry.timestamp).fromNow()}</span>
|
||||
<div class="content">{entry.content}</div>
|
||||
<span class="time">{dayjs(entry.timestamp).fromNow()}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
@@ -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;
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
170
src/lib/components/Queries/Queries.svelte
Normal file
170
src/lib/components/Queries/Queries.svelte
Normal file
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import { get_app_context } from '$lib/context';
|
||||
import type { Query } from '$lib/repositories/queries';
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface Props {
|
||||
queries?: Query[];
|
||||
|
||||
onopen?: (query: Query) => MaybePromise<void>;
|
||||
ondelete?: (query: Query) => MaybePromise<void>;
|
||||
onrename?: (query: Query) => MaybePromise<void>;
|
||||
}
|
||||
|
||||
let { queries = [], onopen, ondelete, onrename }: Props = $props();
|
||||
|
||||
const { context_menu } = get_app_context();
|
||||
|
||||
let editing_id = $state<Query['id']>();
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
editing_id = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
let input = $state.raw<HTMLInputElement>();
|
||||
$effect(() => input?.focus());
|
||||
</script>
|
||||
|
||||
<ol role="menu">
|
||||
{#each queries as query (query.id)}
|
||||
<li
|
||||
tabindex="-1"
|
||||
oncontextmenu={(e) => {
|
||||
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();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{#if editing_id === query.id}
|
||||
<form
|
||||
onsubmit={async (e) => {
|
||||
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;
|
||||
}}
|
||||
>
|
||||
<input
|
||||
bind:this={input}
|
||||
type="text"
|
||||
name="name"
|
||||
value={query.name}
|
||||
onkeydown={handleKeydown}
|
||||
onblur={() => (editing_id = undefined)}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</form>
|
||||
{:else}
|
||||
<span class="name">{query.name}</span>
|
||||
{/if}
|
||||
<span class="time">{dayjs(query.createdAt).fromNow()}</span>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<style>
|
||||
ol {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
li {
|
||||
padding: 3px 5px;
|
||||
border-radius: 3px;
|
||||
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
|
||||
& > div {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
|
||||
& > form {
|
||||
width: 100%;
|
||||
|
||||
& > input {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 18px;
|
||||
outline: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
& > span.name {
|
||||
display: block;
|
||||
height: 18px;
|
||||
padding: 3px 0;
|
||||
line-height: 1.15;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
& > span.time {
|
||||
font-size: 10px;
|
||||
color: hsl(0deg 0% 96%);
|
||||
}
|
||||
}
|
||||
|
||||
&:is(:focus-within) {
|
||||
outline: none;
|
||||
background-color: hsl(0deg 0% 19% / 100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
115
src/lib/components/Queries/SaveQueryModal.svelte
Normal file
115
src/lib/components/Queries/SaveQueryModal.svelte
Normal file
@@ -0,0 +1,115 @@
|
||||
<script lang="ts">
|
||||
import type { FormEventHandler } from 'svelte/elements';
|
||||
import Modal from '../Modal.svelte';
|
||||
|
||||
interface Props {
|
||||
onCreate?: (query: { name: string }) => MaybePromise<unknown>;
|
||||
}
|
||||
|
||||
let { onCreate }: Props = $props();
|
||||
|
||||
let modal = $state<ReturnType<typeof Modal>>();
|
||||
let open = $state(false);
|
||||
|
||||
async function handleSubmit(e: Parameters<FormEventHandler<HTMLFormElement>>[0]) {
|
||||
e.preventDefault();
|
||||
const form_data = new FormData(e.currentTarget);
|
||||
|
||||
const name = form_data.get('name') as string;
|
||||
|
||||
try {
|
||||
await onCreate?.({ name });
|
||||
modal?.close();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
let name_value = $state('');
|
||||
$effect(() => {
|
||||
open;
|
||||
|
||||
name_value = '';
|
||||
});
|
||||
|
||||
export function show() {
|
||||
open = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<Modal onclose={() => (open = false)} bind:this={modal}>
|
||||
<form onsubmit={handleSubmit}>
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Price"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
name="name"
|
||||
bind:value={name_value}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="Actions">
|
||||
<button type="button" onclick={() => modal!.close()}>Cancel</button>
|
||||
<button type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
form {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
form label {
|
||||
display: block;
|
||||
|
||||
& > span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
input[type='text'] {
|
||||
width: 100%;
|
||||
background-color: hsl(0deg 0% 3%);
|
||||
border: 1px solid hsl(0deg 0% 3%);
|
||||
border-radius: 3px;
|
||||
padding: 5px 10px;
|
||||
|
||||
outline: none;
|
||||
caret-color: currentcolor;
|
||||
|
||||
&:not(:read-only):is(:focus-within, :hover) {
|
||||
border-color: hsl(0deg 0% 34%);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
appearance: none;
|
||||
outline: none;
|
||||
border: none;
|
||||
text-wrap: nowrap;
|
||||
background-color: hsl(0deg 0% 33%);
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
|
||||
&:is(:active) {
|
||||
background-color: hsl(0deg 0% 52%);
|
||||
}
|
||||
}
|
||||
|
||||
div.Actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
height: 28px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
2
src/lib/components/Queries/index.ts
Normal file
2
src/lib/components/Queries/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Queries } from './Queries.svelte';
|
||||
export { default as SaveQueryModal } from './SaveQueryModal.svelte';
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import type { Table } from '$lib/olap-engine';
|
||||
import type { HistoryEntry } from '$lib/repositories/history';
|
||||
import type { Query } from '$lib/repositories/queries';
|
||||
import Datasets from './Datasets/Datasets.svelte';
|
||||
import History from './History.svelte';
|
||||
import Queries from './Queries/Queries.svelte';
|
||||
|
||||
type Tab = 'sources' | 'queries' | 'history';
|
||||
|
||||
@@ -13,11 +15,25 @@
|
||||
|
||||
type Props = {
|
||||
tables?: Table[];
|
||||
|
||||
history?: HistoryEntry[];
|
||||
onHistoryClick?: (entry: HistoryEntry) => void;
|
||||
|
||||
queries?: Query[];
|
||||
onQueryOpen?: (query: Query) => MaybePromise<void>;
|
||||
onQueryRename?: (query: Query) => MaybePromise<void>;
|
||||
onQueryDelete?: (query: Query) => MaybePromise<void>;
|
||||
};
|
||||
|
||||
let { tables = [], history = [], onHistoryClick }: Props = $props();
|
||||
let {
|
||||
tables = [],
|
||||
history = [],
|
||||
onHistoryClick,
|
||||
queries = [],
|
||||
onQueryDelete,
|
||||
onQueryOpen,
|
||||
onQueryRename
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<section>
|
||||
@@ -29,6 +45,9 @@
|
||||
{#if tab === 'sources'}
|
||||
<Datasets {tables} />
|
||||
{/if}
|
||||
{#if tab === 'queries'}
|
||||
<Queries {queries} ondelete={onQueryDelete} onopen={onQueryOpen} onrename={onQueryRename} />
|
||||
{/if}
|
||||
{#if tab === 'history'}
|
||||
<History {history} {onHistoryClick} />
|
||||
{/if}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
16
src/lib/migrations/002_create_queries_table.sql
Normal file
16
src/lib/migrations/002_create_queries_table.sql
Normal file
@@ -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;
|
||||
@@ -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 }
|
||||
];
|
||||
|
||||
@@ -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<HistoryEntry[]>;
|
||||
getAll(): Promise<HistoryEntry[]>;
|
||||
add(content: string): Promise<HistoryEntry>;
|
||||
}
|
||||
|
||||
class SQLiteHistoryRepository implements HistoryRepository {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
async get_all(): Promise<HistoryEntry[]> {
|
||||
async getAll(): Promise<HistoryEntry[]> {
|
||||
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()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
79
src/lib/repositories/queries.ts
Normal file
79
src/lib/repositories/queries.ts
Normal file
@@ -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<Query[]>;
|
||||
getById(id: number): Promise<Query>;
|
||||
create(name: string, sql: string): Promise<Query>;
|
||||
update(query: Query): Promise<Query>;
|
||||
delete(id: number): Promise<void>;
|
||||
}
|
||||
|
||||
class SQLiteQueryRepository implements QueryRepository {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
async getAll(): Promise<Query[]> {
|
||||
const rows = await this.db.exec('SELECT * FROM queries');
|
||||
return rows.map((row) => row_to_query(row));
|
||||
}
|
||||
|
||||
async getById(id: number): Promise<Query> {
|
||||
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<Query> {
|
||||
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<Query> {
|
||||
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<void> {
|
||||
await this.db.exec('DELETE FROM queries WHERE id = ?', [id]);
|
||||
}
|
||||
}
|
||||
|
||||
function row_to_query(row: Awaited<ReturnType<Database['exec']>>[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);
|
||||
4
src/lib/types.d.ts
vendored
4
src/lib/types.d.ts
vendored
@@ -1,5 +1,5 @@
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
import type { ContextMenuState } from './components/ContextMenu';
|
||||
|
||||
export type AppContext = {
|
||||
tables: Table[];
|
||||
context_menu: ContextMenuState;
|
||||
};
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { ContextMenuState } from '$lib/components/ContextMenu';
|
||||
import ContextMenu from '$lib/components/ContextMenu/ContextMenu.svelte';
|
||||
import { Editor } from '$lib/components/Editor';
|
||||
import { SaveQueryModal } from '$lib/components/Queries';
|
||||
import Result from '$lib/components/Result.svelte';
|
||||
import SideBar from '$lib/components/SideBar.svelte';
|
||||
import { SplitPane } from '$lib/components/SplitPane';
|
||||
import WindowTitleBar from '$lib/components/WindowTitleBar.svelte';
|
||||
import { set_app_context } from '$lib/context';
|
||||
import type { Table } from '$lib/olap-engine';
|
||||
import { engine, type OLAPResponse } from '$lib/olap-engine';
|
||||
import { history_repository, type HistoryEntry } from '$lib/repositories/history';
|
||||
import { query_repository, type Query } from '$lib/repositories/queries';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let response = $state.raw<OLAPResponse>();
|
||||
@@ -23,8 +28,9 @@
|
||||
if (response) await addHistoryEntry();
|
||||
}
|
||||
|
||||
let tables = $state<Table[]>([]);
|
||||
let history = $state<HistoryEntry[]>([]);
|
||||
let tables = $state.raw<Table[]>([]);
|
||||
let history = $state.raw<HistoryEntry[]>([]);
|
||||
let queries = $state.raw<Query[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
engine.getSchema().then((t) => {
|
||||
@@ -33,7 +39,7 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
history_repository.get_all().then((entries) => {
|
||||
history_repository.getAll().then((entries) => {
|
||||
history = entries;
|
||||
});
|
||||
});
|
||||
@@ -50,10 +56,33 @@
|
||||
function handleHistoryClick(entry: HistoryEntry) {
|
||||
query = entry.content;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
query_repository.getAll().then((q) => (queries = q));
|
||||
});
|
||||
|
||||
let save_query_modal = $state<ReturnType<typeof SaveQueryModal>>();
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 's' && event.metaKey) {
|
||||
if (query) {
|
||||
event.preventDefault();
|
||||
save_query_modal?.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const context_menu = new ContextMenuState();
|
||||
set_app_context({ context_menu });
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} />
|
||||
|
||||
<ContextMenu state={context_menu} />
|
||||
|
||||
<WindowTitleBar>
|
||||
{#snippet actions()}
|
||||
<button onclick={() => save_query_modal?.show()} disabled={!query}>Save</button>
|
||||
<button onclick={handleExec} disabled={loading}>Run</button>
|
||||
{/snippet}
|
||||
</WindowTitleBar>
|
||||
@@ -61,7 +90,30 @@
|
||||
<section class="screen">
|
||||
<SplitPane orientation="horizontal" position="242px" min="242px" max="40%">
|
||||
{#snippet a()}
|
||||
<SideBar {tables} {history} onHistoryClick={handleHistoryClick} />
|
||||
<SideBar
|
||||
{tables}
|
||||
{history}
|
||||
onHistoryClick={handleHistoryClick}
|
||||
{queries}
|
||||
onQueryDelete={async (query) => {
|
||||
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()}
|
||||
<SplitPane orientation="vertical" min="20%" max="80%" --color="hsl(0deg 0% 12%)">
|
||||
@@ -76,6 +128,14 @@
|
||||
</SplitPane>
|
||||
</section>
|
||||
|
||||
<SaveQueryModal
|
||||
bind:this={save_query_modal}
|
||||
onCreate={async ({ name }) => {
|
||||
const q = await query_repository.create(name, query);
|
||||
queries = queries.concat(q);
|
||||
}}
|
||||
/>
|
||||
|
||||
<style>
|
||||
button {
|
||||
appearance: none;
|
||||
@@ -87,9 +147,8 @@
|
||||
padding: 4px 10px;
|
||||
border-radius: 3px;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&:is(:hover, :focus-within) {
|
||||
&:is(:hover, :focus-within):not(:disabled) {
|
||||
cursor: pointer;
|
||||
background-color: hsl(0deg 0% 15%);
|
||||
}
|
||||
}
|
||||
|
||||
2
src/vite-env.d.ts
vendored
2
src/vite-env.d.ts
vendored
@@ -1 +1,3 @@
|
||||
declare const FORCE_REMOTE_ENGINE: string;
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
Reference in New Issue
Block a user