annotate-kit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +307 -0
- package/backend/firebase/firestore.indexes.json +22 -0
- package/backend/firebase/firestore.rules +84 -0
- package/backend/firebase/storage.rules +37 -0
- package/backend/supabase/migration.sql +245 -0
- package/dist/adapter-Cyqfwj6F.d.ts +40 -0
- package/dist/annotate-kit.css +2 -0
- package/dist/chunk-G44KPHPN.js +107 -0
- package/dist/delivery.d.ts +31 -0
- package/dist/delivery.js +48 -0
- package/dist/firebase.d.ts +34 -0
- package/dist/firebase.js +200 -0
- package/dist/idb.d.ts +18 -0
- package/dist/idb.js +202 -0
- package/dist/index.d.ts +290 -0
- package/dist/index.js +1856 -0
- package/dist/local.d.ts +18 -0
- package/dist/local.js +115 -0
- package/dist/prompt.d.ts +54 -0
- package/dist/prompt.js +8 -0
- package/dist/rest.d.ts +27 -0
- package/dist/rest.js +63 -0
- package/dist/supabase.d.ts +31 -0
- package/dist/supabase.js +143 -0
- package/dist/types-BLXmj4Oi.d.ts +93 -0
- package/package.json +143 -0
- package/src/Annotate.tsx +1043 -0
- package/src/ScreenshotEditor.tsx +195 -0
- package/src/adapter.ts +31 -0
- package/src/adapters/firebase.ts +197 -0
- package/src/adapters/idb.ts +181 -0
- package/src/adapters/local.ts +114 -0
- package/src/adapters/rest.ts +98 -0
- package/src/adapters/supabase.ts +185 -0
- package/src/constants.ts +35 -0
- package/src/delivery.ts +63 -0
- package/src/index.ts +20 -0
- package/src/prompt.ts +109 -0
- package/src/snapshot.ts +21 -0
- package/src/styles/annotate.css +211 -0
- package/src/types.ts +71 -0
package/dist/local.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { S as StorageAdapter } from './adapter-Cyqfwj6F.js';
|
|
2
|
+
import './types-BLXmj4Oi.js';
|
|
3
|
+
|
|
4
|
+
type LocalAdapterConfig = {
|
|
5
|
+
/** localStorage key holding all data (default 'annotate-kit'). */
|
|
6
|
+
storageKey?: string;
|
|
7
|
+
/** The single local user shown as author. */
|
|
8
|
+
me?: {
|
|
9
|
+
email?: string;
|
|
10
|
+
name?: string;
|
|
11
|
+
role?: string;
|
|
12
|
+
};
|
|
13
|
+
/** Storage backend (default globalThis.localStorage). Pass a mock for tests / non-browser envs. */
|
|
14
|
+
storage?: Storage;
|
|
15
|
+
};
|
|
16
|
+
declare function createLocalAdapter(config?: LocalAdapterConfig): StorageAdapter;
|
|
17
|
+
|
|
18
|
+
export { type LocalAdapterConfig, createLocalAdapter };
|
package/dist/local.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// src/adapters/local.ts
|
|
2
|
+
function createLocalAdapter(config = {}) {
|
|
3
|
+
const key = config.storageKey ?? "annotate-kit";
|
|
4
|
+
const me = config.me ?? { email: "you@local", name: "You" };
|
|
5
|
+
const store = config.storage ?? (typeof localStorage !== "undefined" ? localStorage : void 0);
|
|
6
|
+
const now = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
7
|
+
const empty = () => ({ seq: 1, rows: [], settings: { enabled: true }, shots: {} });
|
|
8
|
+
const read = () => {
|
|
9
|
+
if (!store) return empty();
|
|
10
|
+
try {
|
|
11
|
+
const raw = store.getItem(key);
|
|
12
|
+
if (!raw) return empty();
|
|
13
|
+
const s = JSON.parse(raw);
|
|
14
|
+
return {
|
|
15
|
+
seq: typeof s.seq === "number" ? s.seq : 1,
|
|
16
|
+
rows: Array.isArray(s.rows) ? s.rows : [],
|
|
17
|
+
settings: s.settings && typeof s.settings.enabled === "boolean" ? s.settings : { enabled: true },
|
|
18
|
+
shots: s.shots && typeof s.shots === "object" ? s.shots : {}
|
|
19
|
+
};
|
|
20
|
+
} catch {
|
|
21
|
+
return empty();
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
const write = (s) => {
|
|
25
|
+
if (!store) return;
|
|
26
|
+
try {
|
|
27
|
+
store.setItem(key, JSON.stringify(s));
|
|
28
|
+
} catch {
|
|
29
|
+
throw new Error("annotate: local storage is full or unavailable \u2014 the change could not be saved");
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
const withUrls = (s) => s.rows.map((r) => ({ ...r, screenshot_url: r.screenshot_path ? s.shots[r.screenshot_path] ?? null : null }));
|
|
33
|
+
return {
|
|
34
|
+
async getAccess() {
|
|
35
|
+
return { enabled: read().settings.enabled, isAdmin: true, canUse: true, seeAll: true, me };
|
|
36
|
+
},
|
|
37
|
+
async setAccess(enabled) {
|
|
38
|
+
const s = read();
|
|
39
|
+
s.settings.enabled = enabled;
|
|
40
|
+
write(s);
|
|
41
|
+
return { message: enabled ? "Enabled" : "Disabled" };
|
|
42
|
+
},
|
|
43
|
+
async list() {
|
|
44
|
+
return withUrls(read()).sort((a, b) => b.id - a.id);
|
|
45
|
+
},
|
|
46
|
+
async save(input) {
|
|
47
|
+
const s = read();
|
|
48
|
+
const id = s.seq++;
|
|
49
|
+
s.rows.push({
|
|
50
|
+
...input,
|
|
51
|
+
id,
|
|
52
|
+
status: "open",
|
|
53
|
+
created_by: me.email ?? "",
|
|
54
|
+
created_by_name: me.name ?? me.email ?? "",
|
|
55
|
+
created_by_role: me.role ?? null,
|
|
56
|
+
created_at: now(),
|
|
57
|
+
updated_at: now()
|
|
58
|
+
});
|
|
59
|
+
write(s);
|
|
60
|
+
return { id };
|
|
61
|
+
},
|
|
62
|
+
async update(patch) {
|
|
63
|
+
const s = read();
|
|
64
|
+
const { id, ...rest } = patch;
|
|
65
|
+
const row = s.rows.find((r) => r.id === id);
|
|
66
|
+
if (row) {
|
|
67
|
+
Object.assign(row, rest, { updated_at: now() });
|
|
68
|
+
write(s);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
async remove(id) {
|
|
72
|
+
const s = read();
|
|
73
|
+
const row = s.rows.find((r) => r.id === id);
|
|
74
|
+
if (row?.screenshot_path) delete s.shots[row.screenshot_path];
|
|
75
|
+
s.rows = s.rows.filter((r) => r.id !== id);
|
|
76
|
+
write(s);
|
|
77
|
+
},
|
|
78
|
+
async addReply(id, note) {
|
|
79
|
+
const s = read();
|
|
80
|
+
const row = s.rows.find((r) => r.id === id);
|
|
81
|
+
if (row) {
|
|
82
|
+
const reply = { note: note.slice(0, 2e3), author: me.name ?? me.email ?? "", at: now() };
|
|
83
|
+
row.replies = [...Array.isArray(row.replies) ? row.replies : [], reply];
|
|
84
|
+
row.updated_at = now();
|
|
85
|
+
write(s);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
async clearResolved() {
|
|
89
|
+
const s = read();
|
|
90
|
+
const before = s.rows.length;
|
|
91
|
+
for (const r of s.rows) if ((r.status === "done" || r.status === "dismissed") && r.screenshot_path) delete s.shots[r.screenshot_path];
|
|
92
|
+
s.rows = s.rows.filter((r) => r.status !== "done" && r.status !== "dismissed");
|
|
93
|
+
const referenced = new Set(s.rows.map((r) => r.screenshot_path).filter(Boolean));
|
|
94
|
+
for (const p of Object.keys(s.shots)) if (!referenced.has(p)) delete s.shots[p];
|
|
95
|
+
write(s);
|
|
96
|
+
return { message: `Cleared ${before - s.rows.length} resolved` };
|
|
97
|
+
},
|
|
98
|
+
async uploadScreenshot(file, _opts) {
|
|
99
|
+
const dataUrl = await new Promise((resolve, reject) => {
|
|
100
|
+
const fr = new FileReader();
|
|
101
|
+
fr.onload = () => resolve(String(fr.result));
|
|
102
|
+
fr.onerror = () => reject(fr.error ?? new Error("read failed"));
|
|
103
|
+
fr.readAsDataURL(file);
|
|
104
|
+
});
|
|
105
|
+
const s = read();
|
|
106
|
+
const path = `shot-${s.seq++}-${Date.now()}`;
|
|
107
|
+
s.shots[path] = dataUrl;
|
|
108
|
+
write(s);
|
|
109
|
+
return { path };
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
export {
|
|
114
|
+
createLocalAdapter
|
|
115
|
+
};
|
package/dist/prompt.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { a as Annotation, d as AnnKind, e as AnnShape, f as AnnType, g as AnnPriority, h as AnnStatus, c as AnnVisibility } from './types-BLXmj4Oi.js';
|
|
2
|
+
|
|
3
|
+
declare function buildDevPrompt(items: Annotation[], opts?: {
|
|
4
|
+
title?: string;
|
|
5
|
+
includeConsole?: boolean;
|
|
6
|
+
includeNetwork?: boolean;
|
|
7
|
+
}): string;
|
|
8
|
+
declare function buildDevContext(items: Annotation[]): {
|
|
9
|
+
total: number;
|
|
10
|
+
items: {
|
|
11
|
+
id: number;
|
|
12
|
+
page_route: string;
|
|
13
|
+
page_title: string;
|
|
14
|
+
kind: AnnKind;
|
|
15
|
+
shape_type: AnnShape | null;
|
|
16
|
+
type: AnnType;
|
|
17
|
+
priority: AnnPriority;
|
|
18
|
+
status: AnnStatus;
|
|
19
|
+
visibility: AnnVisibility;
|
|
20
|
+
element: {
|
|
21
|
+
selector: string | null;
|
|
22
|
+
text: string | null;
|
|
23
|
+
};
|
|
24
|
+
where: string[];
|
|
25
|
+
note: string;
|
|
26
|
+
author: string | null;
|
|
27
|
+
env: {
|
|
28
|
+
browser: string | null;
|
|
29
|
+
os: string | null;
|
|
30
|
+
device: string | null;
|
|
31
|
+
viewport: string | null;
|
|
32
|
+
url: string | null;
|
|
33
|
+
} | null;
|
|
34
|
+
replies: {
|
|
35
|
+
note: string;
|
|
36
|
+
author: string | null;
|
|
37
|
+
at: string | null;
|
|
38
|
+
}[];
|
|
39
|
+
console: {
|
|
40
|
+
level: string;
|
|
41
|
+
text: string;
|
|
42
|
+
}[];
|
|
43
|
+
network: {
|
|
44
|
+
method: string;
|
|
45
|
+
url: string;
|
|
46
|
+
status: number | null;
|
|
47
|
+
ms: number | null;
|
|
48
|
+
}[];
|
|
49
|
+
domSnapshot: string | null;
|
|
50
|
+
hasScreenshot: boolean;
|
|
51
|
+
}[];
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export { buildDevContext, buildDevPrompt };
|
package/dist/prompt.js
ADDED
package/dist/rest.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { S as StorageAdapter } from './adapter-Cyqfwj6F.js';
|
|
2
|
+
import './types-BLXmj4Oi.js';
|
|
3
|
+
|
|
4
|
+
type RestEndpoints = {
|
|
5
|
+
access: string;
|
|
6
|
+
list: string;
|
|
7
|
+
create: string;
|
|
8
|
+
item: (id: number) => string;
|
|
9
|
+
replies: (id: number) => string;
|
|
10
|
+
clearResolved: string;
|
|
11
|
+
screenshots: string;
|
|
12
|
+
};
|
|
13
|
+
type RestAdapterConfig = {
|
|
14
|
+
/** Base URL of your annotations API, e.g. '/api/annotations' or 'https://api.example.com/annotations'. */
|
|
15
|
+
baseUrl: string;
|
|
16
|
+
/** Extra headers (e.g. Authorization). A function is re-evaluated per request, so tokens stay fresh. */
|
|
17
|
+
headers?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);
|
|
18
|
+
/** Cookie policy for requests (default 'same-origin'). Use 'include' for cross-origin cookies. */
|
|
19
|
+
credentials?: RequestCredentials;
|
|
20
|
+
/** Custom fetch implementation (default: global fetch). */
|
|
21
|
+
fetch?: typeof fetch;
|
|
22
|
+
/** Override any endpoint (absolute URL or relative to baseUrl). */
|
|
23
|
+
endpoints?: Partial<RestEndpoints>;
|
|
24
|
+
};
|
|
25
|
+
declare function createRestAdapter(config: RestAdapterConfig): StorageAdapter;
|
|
26
|
+
|
|
27
|
+
export { type RestAdapterConfig, type RestEndpoints, createRestAdapter };
|
package/dist/rest.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// src/adapters/rest.ts
|
|
2
|
+
function createRestAdapter(config) {
|
|
3
|
+
const base = config.baseUrl.replace(/\/+$/, "");
|
|
4
|
+
const doFetch = config.fetch ?? (typeof fetch !== "undefined" ? fetch : void 0);
|
|
5
|
+
if (!doFetch) throw new Error("annotate: no fetch available \u2014 pass config.fetch");
|
|
6
|
+
const ep = {
|
|
7
|
+
access: `${base}/access`,
|
|
8
|
+
list: base,
|
|
9
|
+
create: base,
|
|
10
|
+
item: (id) => `${base}/${encodeURIComponent(String(id))}`,
|
|
11
|
+
replies: (id) => `${base}/${encodeURIComponent(String(id))}/replies`,
|
|
12
|
+
clearResolved: `${base}/clear-resolved`,
|
|
13
|
+
screenshots: `${base}/screenshots`,
|
|
14
|
+
...config.endpoints
|
|
15
|
+
};
|
|
16
|
+
async function baseHeaders() {
|
|
17
|
+
return typeof config.headers === "function" ? await config.headers() : config.headers ?? {};
|
|
18
|
+
}
|
|
19
|
+
async function req(url, opts) {
|
|
20
|
+
const headers = { ...await baseHeaders() };
|
|
21
|
+
if (opts?.json !== void 0) headers["content-type"] = "application/json";
|
|
22
|
+
const res = await doFetch(url, {
|
|
23
|
+
method: opts?.method ?? "GET",
|
|
24
|
+
credentials: config.credentials ?? "same-origin",
|
|
25
|
+
headers,
|
|
26
|
+
body: opts?.json !== void 0 ? JSON.stringify(opts.json) : opts?.body
|
|
27
|
+
});
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
const detail = await res.text().catch(() => res.statusText);
|
|
30
|
+
throw new Error(`annotate REST ${res.status}: ${detail.slice(0, 300)}`);
|
|
31
|
+
}
|
|
32
|
+
const text = await res.text();
|
|
33
|
+
if (!text) return void 0;
|
|
34
|
+
const ct = res.headers.get("content-type") || "";
|
|
35
|
+
return ct.includes("json") ? JSON.parse(text) : text;
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
getAccess: () => req(ep.access),
|
|
39
|
+
setAccess: (enabled) => req(ep.access, { method: "PUT", json: { enabled } }),
|
|
40
|
+
list: () => req(ep.list).then((r) => r ?? []),
|
|
41
|
+
save: (input) => req(ep.create, { method: "POST", json: input }),
|
|
42
|
+
async update(patch) {
|
|
43
|
+
const { id, ...rest } = patch;
|
|
44
|
+
await req(ep.item(id), { method: "PATCH", json: rest });
|
|
45
|
+
},
|
|
46
|
+
async remove(id) {
|
|
47
|
+
await req(ep.item(id), { method: "DELETE" });
|
|
48
|
+
},
|
|
49
|
+
async addReply(id, note) {
|
|
50
|
+
await req(ep.replies(id), { method: "POST", json: { note } });
|
|
51
|
+
},
|
|
52
|
+
clearResolved: () => req(ep.clearResolved, { method: "POST" }),
|
|
53
|
+
async uploadScreenshot(file, opts) {
|
|
54
|
+
const fd = new FormData();
|
|
55
|
+
fd.append("file", file);
|
|
56
|
+
if (opts?.visibility) fd.append("visibility", opts.visibility);
|
|
57
|
+
return req(ep.screenshots, { method: "POST", body: fd });
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export {
|
|
62
|
+
createRestAdapter
|
|
63
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { SupabaseClient } from '@supabase/supabase-js';
|
|
2
|
+
import { S as StorageAdapter } from './adapter-Cyqfwj6F.js';
|
|
3
|
+
import './types-BLXmj4Oi.js';
|
|
4
|
+
|
|
5
|
+
type SupabaseAdapterConfig = {
|
|
6
|
+
client: SupabaseClient;
|
|
7
|
+
/** Table name (default 'annotations'). */
|
|
8
|
+
table?: string;
|
|
9
|
+
/** Private storage bucket for screenshots (default 'annotations'). */
|
|
10
|
+
bucket?: string;
|
|
11
|
+
/** Single-row global toggle table (default 'annotate_settings'). */
|
|
12
|
+
settingsTable?: string;
|
|
13
|
+
/** Signed-URL lifetime for screenshots, seconds (default 3600). */
|
|
14
|
+
signedUrlTtl?: number;
|
|
15
|
+
/** Max rows fetched per list() call (default 1000). Keeps polling bounded on busy projects. */
|
|
16
|
+
listLimit?: number;
|
|
17
|
+
/** Is the current viewer an admin? A boolean, or a predicate over the resolved user.
|
|
18
|
+
* Default: true when the auth user's `app_metadata.role === 'admin'` or `app_metadata.annotate_admin === true`
|
|
19
|
+
* (must match backend/supabase/migration.sql's annotate_is_admin()). */
|
|
20
|
+
isAdmin?: boolean | ((user: ResolvedUser) => boolean | Promise<boolean>);
|
|
21
|
+
/** Override how the current user is resolved (default: supabase.auth.getUser()). */
|
|
22
|
+
currentUser?: () => Promise<ResolvedUser | null>;
|
|
23
|
+
};
|
|
24
|
+
type ResolvedUser = {
|
|
25
|
+
email?: string;
|
|
26
|
+
name?: string;
|
|
27
|
+
role?: string;
|
|
28
|
+
};
|
|
29
|
+
declare function createSupabaseAdapter(config: SupabaseAdapterConfig): StorageAdapter;
|
|
30
|
+
|
|
31
|
+
export { type ResolvedUser, type SupabaseAdapterConfig, createSupabaseAdapter };
|
package/dist/supabase.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// src/adapters/supabase.ts
|
|
2
|
+
function createSupabaseAdapter(config) {
|
|
3
|
+
const client = config.client;
|
|
4
|
+
const table = config.table ?? "annotations";
|
|
5
|
+
const bucket = config.bucket ?? "annotations";
|
|
6
|
+
const settingsTable = config.settingsTable ?? "annotate_settings";
|
|
7
|
+
const ttl = config.signedUrlTtl ?? 3600;
|
|
8
|
+
const listLimit = config.listLimit ?? 1e3;
|
|
9
|
+
const urlCache = /* @__PURE__ */ new Map();
|
|
10
|
+
async function resolveUser() {
|
|
11
|
+
if (config.currentUser) return await config.currentUser() ?? {};
|
|
12
|
+
const { data } = await client.auth.getUser();
|
|
13
|
+
const u = data.user;
|
|
14
|
+
if (!u) return {};
|
|
15
|
+
const md = u.user_metadata ?? {};
|
|
16
|
+
const am = u.app_metadata ?? {};
|
|
17
|
+
return {
|
|
18
|
+
email: u.email ?? md.email,
|
|
19
|
+
name: md.full_name || md.name || u.email || void 0,
|
|
20
|
+
role: am.role || md.role || void 0
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
async function resolveIsAdmin(user) {
|
|
24
|
+
if (typeof config.isAdmin === "boolean") return config.isAdmin;
|
|
25
|
+
if (typeof config.isAdmin === "function") return !!await config.isAdmin(user);
|
|
26
|
+
const { data } = await client.auth.getUser();
|
|
27
|
+
const am = data.user?.app_metadata ?? {};
|
|
28
|
+
return am.role === "admin" || am.annotate_admin === true;
|
|
29
|
+
}
|
|
30
|
+
async function uid() {
|
|
31
|
+
const { data } = await client.auth.getUser();
|
|
32
|
+
if (!data.user?.id) throw new Error("annotate: must be signed in to upload a screenshot");
|
|
33
|
+
return data.user.id;
|
|
34
|
+
}
|
|
35
|
+
const err = (e) => {
|
|
36
|
+
if (e?.message) throw new Error(e.message);
|
|
37
|
+
};
|
|
38
|
+
async function signPaths(rows) {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
const need = [];
|
|
41
|
+
for (const r of rows) {
|
|
42
|
+
if (!r.screenshot_path) continue;
|
|
43
|
+
const hit = urlCache.get(r.screenshot_path);
|
|
44
|
+
if (hit && hit.exp - now > 6e4) r.screenshot_url = hit.url;
|
|
45
|
+
else need.push(r.screenshot_path);
|
|
46
|
+
}
|
|
47
|
+
if (need.length) {
|
|
48
|
+
const { data } = await client.storage.from(bucket).createSignedUrls(need, ttl);
|
|
49
|
+
const byPath = new Map((data ?? []).map((d) => [d.path, d.signedUrl]));
|
|
50
|
+
const exp = now + ttl * 1e3;
|
|
51
|
+
for (const r of rows) {
|
|
52
|
+
if (r.screenshot_path && !r.screenshot_url) {
|
|
53
|
+
const url = byPath.get(r.screenshot_path) ?? null;
|
|
54
|
+
if (url) urlCache.set(r.screenshot_path, { url, exp });
|
|
55
|
+
r.screenshot_url = url;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
async getAccess() {
|
|
62
|
+
const me = await resolveUser();
|
|
63
|
+
const isAdmin = await resolveIsAdmin(me);
|
|
64
|
+
let enabled = false;
|
|
65
|
+
try {
|
|
66
|
+
const { data } = await client.from(settingsTable).select("value").eq("key", "enabled").maybeSingle();
|
|
67
|
+
if (data && data.value != null) enabled = data.value === true || data.value === "true";
|
|
68
|
+
} catch {
|
|
69
|
+
}
|
|
70
|
+
return { enabled, isAdmin, canUse: enabled || isAdmin, seeAll: isAdmin, me };
|
|
71
|
+
},
|
|
72
|
+
async setAccess(enabled) {
|
|
73
|
+
const { error } = await client.from(settingsTable).upsert({ key: "enabled", value: enabled }, { onConflict: "key" });
|
|
74
|
+
err(error);
|
|
75
|
+
return { message: enabled ? "Enabled for all users" : "ADMIN-only" };
|
|
76
|
+
},
|
|
77
|
+
async list() {
|
|
78
|
+
const { data, error } = await client.from(table).select("*").order("id", { ascending: false }).limit(listLimit);
|
|
79
|
+
err(error);
|
|
80
|
+
const rows = data ?? [];
|
|
81
|
+
await signPaths(rows);
|
|
82
|
+
return rows;
|
|
83
|
+
},
|
|
84
|
+
async save(input) {
|
|
85
|
+
const { data, error } = await client.from(table).insert({ ...input, status: "open" }).select("id").single();
|
|
86
|
+
err(error);
|
|
87
|
+
return { id: data.id };
|
|
88
|
+
},
|
|
89
|
+
async update(patch) {
|
|
90
|
+
const { id, ...rest } = patch;
|
|
91
|
+
const { error } = await client.from(table).update(rest).eq("id", id);
|
|
92
|
+
err(error);
|
|
93
|
+
},
|
|
94
|
+
async remove(id) {
|
|
95
|
+
const { data } = await client.from(table).select("screenshot_path").eq("id", id).maybeSingle();
|
|
96
|
+
const { error } = await client.from(table).delete().eq("id", id);
|
|
97
|
+
err(error);
|
|
98
|
+
const path = data?.screenshot_path;
|
|
99
|
+
if (path) {
|
|
100
|
+
urlCache.delete(path);
|
|
101
|
+
const { error: se } = await client.storage.from(bucket).remove([path]);
|
|
102
|
+
if (se) console.warn("[annotate] screenshot delete failed", se.message);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
async addReply(id, note) {
|
|
106
|
+
const { error } = await client.rpc("annotate_add_reply", { p_id: id, p_note: note });
|
|
107
|
+
err(error);
|
|
108
|
+
},
|
|
109
|
+
async clearResolved() {
|
|
110
|
+
const { data } = await client.from(table).select("id, screenshot_path").in("status", ["done", "dismissed"]);
|
|
111
|
+
const rows = data ?? [];
|
|
112
|
+
const ids = rows.map((r) => r.id);
|
|
113
|
+
const paths = rows.map((r) => r.screenshot_path).filter(Boolean);
|
|
114
|
+
if (ids.length) {
|
|
115
|
+
const { error } = await client.from(table).delete().in("id", ids);
|
|
116
|
+
err(error);
|
|
117
|
+
}
|
|
118
|
+
if (paths.length) {
|
|
119
|
+
paths.forEach((p) => urlCache.delete(p));
|
|
120
|
+
const { error: se } = await client.storage.from(bucket).remove(paths);
|
|
121
|
+
if (se) console.warn("[annotate] screenshot cleanup failed", se.message);
|
|
122
|
+
}
|
|
123
|
+
return { message: `Cleared ${ids.length} resolved` };
|
|
124
|
+
},
|
|
125
|
+
async uploadScreenshot(file, opts) {
|
|
126
|
+
const vis = opts?.visibility === "public" ? "public" : "private";
|
|
127
|
+
const rand = Math.random().toString(36).slice(2, 8);
|
|
128
|
+
const path = `${vis}/${await uid()}/${Date.now()}-${rand}.png`;
|
|
129
|
+
const { error } = await client.storage.from(bucket).upload(path, file, { contentType: file.type || "image/png", upsert: false });
|
|
130
|
+
err(error);
|
|
131
|
+
return { path };
|
|
132
|
+
},
|
|
133
|
+
subscribe(onChange) {
|
|
134
|
+
const channel = client.channel(`annotate:${table}:${Math.random().toString(36).slice(2, 8)}`).on("postgres_changes", { event: "*", schema: "public", table }, () => onChange()).subscribe();
|
|
135
|
+
return () => {
|
|
136
|
+
void client.removeChannel(channel);
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
export {
|
|
142
|
+
createSupabaseAdapter
|
|
143
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
type AnnType = 'change' | 'add' | 'remove' | 'bug' | 'style' | 'question';
|
|
2
|
+
type AnnPriority = 'low' | 'med' | 'high';
|
|
3
|
+
type AnnStatus = 'open' | 'in_progress' | 'done' | 'dismissed';
|
|
4
|
+
type AnnKind = 'pin' | 'shape';
|
|
5
|
+
type AnnShape = 'rect' | 'arrow' | 'circle' | 'freehand';
|
|
6
|
+
type AnnVisibility = 'public' | 'private';
|
|
7
|
+
type AnnReply = {
|
|
8
|
+
note: string;
|
|
9
|
+
author?: string;
|
|
10
|
+
at?: string;
|
|
11
|
+
};
|
|
12
|
+
type AnnPoint = {
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
};
|
|
16
|
+
type AnnMeta = {
|
|
17
|
+
browser?: string;
|
|
18
|
+
os?: string;
|
|
19
|
+
device?: string;
|
|
20
|
+
viewport?: string;
|
|
21
|
+
screen?: string;
|
|
22
|
+
dpr?: number;
|
|
23
|
+
url?: string;
|
|
24
|
+
ua?: string;
|
|
25
|
+
};
|
|
26
|
+
type AnnLog = {
|
|
27
|
+
level: string;
|
|
28
|
+
text: string;
|
|
29
|
+
at?: string;
|
|
30
|
+
};
|
|
31
|
+
type AnnNetLog = {
|
|
32
|
+
method: string;
|
|
33
|
+
url: string;
|
|
34
|
+
status?: number;
|
|
35
|
+
ms?: number;
|
|
36
|
+
ok?: boolean;
|
|
37
|
+
at?: string;
|
|
38
|
+
};
|
|
39
|
+
type Annotation = {
|
|
40
|
+
id: number;
|
|
41
|
+
page_route: string;
|
|
42
|
+
page_title: string;
|
|
43
|
+
target_selector: string;
|
|
44
|
+
target_text: string;
|
|
45
|
+
pos_x: number;
|
|
46
|
+
pos_y: number;
|
|
47
|
+
viewport_w: number;
|
|
48
|
+
viewport_h: number;
|
|
49
|
+
note: string;
|
|
50
|
+
type: AnnType;
|
|
51
|
+
priority: AnnPriority;
|
|
52
|
+
status: AnnStatus;
|
|
53
|
+
screenshot_path?: string | null;
|
|
54
|
+
screenshot_url?: string | null;
|
|
55
|
+
replies?: AnnReply[];
|
|
56
|
+
created_by?: string;
|
|
57
|
+
created_at?: string;
|
|
58
|
+
updated_at?: string;
|
|
59
|
+
kind?: AnnKind;
|
|
60
|
+
shape_type?: AnnShape | null;
|
|
61
|
+
x2?: number | null;
|
|
62
|
+
y2?: number | null;
|
|
63
|
+
points?: AnnPoint[] | null;
|
|
64
|
+
color?: string | null;
|
|
65
|
+
visibility?: AnnVisibility;
|
|
66
|
+
created_by_role?: string | null;
|
|
67
|
+
created_by_name?: string | null;
|
|
68
|
+
styles?: Record<string, string> | null;
|
|
69
|
+
el_context?: string[] | null;
|
|
70
|
+
meta?: AnnMeta | null;
|
|
71
|
+
console_logs?: AnnLog[] | null;
|
|
72
|
+
network_logs?: AnnNetLog[] | null;
|
|
73
|
+
dom_snapshot?: string | null;
|
|
74
|
+
};
|
|
75
|
+
type NewAnnotation = Omit<Annotation, 'id' | 'status' | 'created_at' | 'updated_at' | 'screenshot_url' | 'created_by' | 'created_by_role' | 'created_by_name'> & {
|
|
76
|
+
screenshot_path?: string | null;
|
|
77
|
+
};
|
|
78
|
+
type AnnotationPatch = {
|
|
79
|
+
id: number;
|
|
80
|
+
} & Partial<Pick<Annotation, 'note' | 'status' | 'type' | 'priority'>>;
|
|
81
|
+
type AnnotateAccess = {
|
|
82
|
+
enabled: boolean;
|
|
83
|
+
isAdmin: boolean;
|
|
84
|
+
canUse: boolean;
|
|
85
|
+
seeAll: boolean;
|
|
86
|
+
me?: {
|
|
87
|
+
email?: string;
|
|
88
|
+
name?: string;
|
|
89
|
+
role?: string;
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export type { AnnotateAccess as A, NewAnnotation as N, Annotation as a, AnnotationPatch as b, AnnVisibility as c, AnnKind as d, AnnShape as e, AnnType as f, AnnPriority as g, AnnStatus as h };
|