From 14dd3f4c8afc08f385e1778bfa1c269ce016e725 Mon Sep 17 00:00:00 2001 From: Siddhant Singh Date: Sun, 17 May 2026 18:59:55 +0530 Subject: [PATCH 1/5] Prompt Gallery Side Panel --- apps/extension/biome.json | 4 + apps/extension/package.json | 3 +- apps/extension/public/manifest.json | 6 +- .../extension/src/background/firebase-user.ts | 29 + .../src/background/prompt-gallery.ts | 523 +++++++++++++++++ apps/extension/src/background/router.ts | 50 ++ .../src/background/storage-backend.ts | 27 +- apps/extension/src/popup/App.tsx | 53 +- apps/extension/src/sidepanel/App.tsx | 55 ++ .../src/sidepanel/PromptGalleryProvider.tsx | 547 ++++++++++++++++++ .../src/sidepanel/PromptGallerySections.tsx | 515 +++++++++++++++++ apps/extension/src/sidepanel/index.html | 12 + apps/extension/src/sidepanel/main.tsx | 5 + .../src/sidepanel/prompt-gallery.runtime.ts | 74 +++ .../src/sidepanel/prompt-gallery.types.ts | 71 +++ .../src/sidepanel/prompt-gallery.utils.ts | 116 ++++ apps/web/src/app/prompts/[slug]/page.tsx | 169 ++++++ apps/web/src/lib/prompt-catalog.ts | 202 +++++++ apps/web/src/lib/site.ts | 1 + firestore.rules | 5 + packages/types/src/index.ts | 26 + pnpm-lock.yaml | 14 + 22 files changed, 2479 insertions(+), 28 deletions(-) create mode 100644 apps/extension/biome.json create mode 100644 apps/extension/src/background/firebase-user.ts create mode 100644 apps/extension/src/background/prompt-gallery.ts create mode 100644 apps/extension/src/sidepanel/App.tsx create mode 100644 apps/extension/src/sidepanel/PromptGalleryProvider.tsx create mode 100644 apps/extension/src/sidepanel/PromptGallerySections.tsx create mode 100644 apps/extension/src/sidepanel/index.html create mode 100644 apps/extension/src/sidepanel/main.tsx create mode 100644 apps/extension/src/sidepanel/prompt-gallery.runtime.ts create mode 100644 apps/extension/src/sidepanel/prompt-gallery.types.ts create mode 100644 apps/extension/src/sidepanel/prompt-gallery.utils.ts create mode 100644 apps/web/src/app/prompts/[slug]/page.tsx create mode 100644 apps/web/src/lib/prompt-catalog.ts diff --git a/apps/extension/biome.json b/apps/extension/biome.json new file mode 100644 index 0000000..096a95f --- /dev/null +++ b/apps/extension/biome.json @@ -0,0 +1,4 @@ +{ + "root": false, + "extends": "//" +} diff --git a/apps/extension/package.json b/apps/extension/package.json index ad4ee83..5acb9bd 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -19,7 +19,8 @@ "preact": "^10.28.4", "radix-ui": "^1.4.3", "react": "19.2.3", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "sonner": "^2.0.7" }, "devDependencies": { "@crxjs/vite-plugin": "^2.0.0-beta.25", diff --git a/apps/extension/public/manifest.json b/apps/extension/public/manifest.json index d8574ff..96c0da5 100644 --- a/apps/extension/public/manifest.json +++ b/apps/extension/public/manifest.json @@ -39,8 +39,12 @@ ], "permissions": [ "storage", - "identity" + "identity", + "sidePanel" ], + "side_panel": { + "default_path": "src/sidepanel/index.html" + }, "optional_host_permissions": [ "https://*/*", "http://*/*" diff --git a/apps/extension/src/background/firebase-user.ts b/apps/extension/src/background/firebase-user.ts new file mode 100644 index 0000000..128ddc3 --- /dev/null +++ b/apps/extension/src/background/firebase-user.ts @@ -0,0 +1,29 @@ +import { AuthManager } from "./auth"; +import { + getFirebaseAuth, + isFirebaseConfigured, + signInToFirebase, +} from "./firebase"; + +export async function getFirebaseUserId() { + if (!isFirebaseConfigured()) { + throw new Error( + "Firebase is not configured in this build. Add the VITE_FIREBASE_* environment variables before enabling Cloud Sync.", + ); + } + + const firebaseAuth = getFirebaseAuth(); + const cachedUser = await AuthManager.getCachedUser(); + + if ( + firebaseAuth.currentUser && + cachedUser?.email && + firebaseAuth.currentUser.email === cachedUser.email + ) { + return firebaseAuth.currentUser.uid; + } + + const token = await AuthManager.getAuthToken(false); + const credential = await signInToFirebase(token); + return credential.user.uid; +} diff --git a/apps/extension/src/background/prompt-gallery.ts b/apps/extension/src/background/prompt-gallery.ts new file mode 100644 index 0000000..dd81f72 --- /dev/null +++ b/apps/extension/src/background/prompt-gallery.ts @@ -0,0 +1,523 @@ +import type { PublicPrompt, SavedPrompt } from "@plenz/types"; +import { + collection, + deleteDoc, + doc, + getDoc, + getDocs, + limit, + orderBy, + query, + serverTimestamp, + setDoc, +} from "firebase/firestore/lite"; +import { AuthManager } from "./auth"; +import { getFirestoreDb, isFirebaseConfigured } from "./firebase"; +import { getFirebaseUserId } from "./firebase-user"; + +const PROMPT_CATALOG_COLLECTION = "prompt_catalog"; +const SAVED_PROMPTS_COLLECTION = "saved_prompts"; +const PUBLIC_PROMPT_LIMIT = 24; +const firebaseProjectId = readEnvVar("VITE_FIREBASE_PROJECT_ID"); +const firebaseApiKey = readEnvVar("VITE_FIREBASE_API_KEY"); + +type PublicPromptSort = "trending" | "newest"; + +interface PromptCatalogDocument { + title?: string; + prompt?: string; + slug?: string; + trendScore?: number; + createdAt?: unknown; + updatedAt?: unknown; + shareEnabled?: boolean; +} + +interface SavedPromptDocument { + title?: string; + prompt?: string; + sourceType?: "catalog" | "custom"; + catalogPromptId?: string | null; + catalogSlug?: string | null; + savedAt?: unknown; + createdAt?: unknown; + updatedAt?: unknown; +} + +interface FirestoreFieldValue { + stringValue?: string; + integerValue?: string; + doubleValue?: number; + booleanValue?: boolean; + timestampValue?: string; +} + +interface FirestoreDocument { + name: string; + fields?: Record; +} + +function assertFirebaseConfigured() { + if (!isFirebaseConfigured()) { + throw new Error( + "Firebase is not configured in this build. Add the VITE_FIREBASE_* environment variables before using the prompt gallery.", + ); + } +} + +function readEnvVar(key: keyof ImportMetaEnv) { + const value = import.meta.env[key]; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : ""; +} + +function normalizeText(value: string, field: string) { + const nextValue = value.trim(); + + if (!nextValue) { + throw new Error(`${field} is required.`); + } + + return nextValue; +} + +function toIsoString(value: unknown): string | null { + if (!value) { + return null; + } + + if ( + typeof value === "object" && + "toDate" in value && + typeof (value as { toDate: () => Date }).toDate === "function" + ) { + return (value as { toDate: () => Date }).toDate().toISOString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + return typeof value === "string" ? value : null; +} + +function toPublicPrompt( + id: string, + data: PromptCatalogDocument, +): PublicPrompt | null { + if (typeof data.title !== "string" || typeof data.prompt !== "string") { + return null; + } + + return { + id, + title: data.title, + prompt: data.prompt, + slug: + typeof data.slug === "string" && data.slug.trim().length > 0 + ? data.slug.trim() + : id, + trendScore: typeof data.trendScore === "number" ? data.trendScore : null, + createdAt: toIsoString(data.createdAt), + updatedAt: toIsoString(data.updatedAt), + canShare: data.shareEnabled !== false, + }; +} + +function toSavedPrompt(id: string, data: SavedPromptDocument): SavedPrompt | null { + if (typeof data.title !== "string" || typeof data.prompt !== "string") { + return null; + } + + const catalogSlug = + typeof data.catalogSlug === "string" && data.catalogSlug.trim().length > 0 + ? data.catalogSlug.trim() + : null; + + return { + id, + title: data.title, + prompt: data.prompt, + sourceType: data.sourceType === "catalog" ? "catalog" : "custom", + catalogPromptId: + typeof data.catalogPromptId === "string" && data.catalogPromptId.trim().length > 0 + ? data.catalogPromptId.trim() + : null, + catalogSlug, + savedAt: toIsoString(data.savedAt), + createdAt: toIsoString(data.createdAt), + updatedAt: toIsoString(data.updatedAt), + canShare: !!catalogSlug, + }; +} + +async function requireSignedInUser(actionLabel: string) { + const user = await AuthManager.getAuthStatus(); + + if (!user) { + throw new Error(`Sign in with Google to ${actionLabel}.`); + } + + return getFirebaseUserId(); +} + +function getSavedPromptRef(userId: string, savedPromptId: string) { + return doc(getFirestoreDb(), "users", userId, SAVED_PROMPTS_COLLECTION, savedPromptId); +} + +function getSavedPromptsCollection(userId: string) { + return collection(getFirestoreDb(), "users", userId, SAVED_PROMPTS_COLLECTION); +} + +function buildFirestoreRestUrl(path: string) { + if (!firebaseProjectId) { + return ""; + } + + const baseUrl = `https://firestore.googleapis.com/v1/projects/${firebaseProjectId}/databases/(default)/documents`; + const keyQuery = firebaseApiKey ? `?key=${firebaseApiKey}` : ""; + return `${baseUrl}/${path}${keyQuery}`; +} + +function getFirestoreDocumentId(documentName: string) { + return documentName.split("/").at(-1) ?? ""; +} + +function getFirestoreStringField( + document: FirestoreDocument, + fieldName: string, +): string | undefined { + return document.fields?.[fieldName]?.stringValue; +} + +function getFirestoreNumberField( + document: FirestoreDocument, + fieldName: string, +): number | undefined { + const fieldValue = document.fields?.[fieldName]; + + if (typeof fieldValue?.doubleValue === "number") { + return fieldValue.doubleValue; + } + + if (typeof fieldValue?.integerValue === "string") { + const numericValue = Number(fieldValue.integerValue); + return Number.isFinite(numericValue) ? numericValue : undefined; + } + + return undefined; +} + +function getFirestoreBooleanField( + document: FirestoreDocument, + fieldName: string, +): boolean | undefined { + const fieldValue = document.fields?.[fieldName]?.booleanValue; + return typeof fieldValue === "boolean" ? fieldValue : undefined; +} + +function getFirestoreTimestampField( + document: FirestoreDocument, + fieldName: string, +): string | undefined { + return document.fields?.[fieldName]?.timestampValue; +} + +function toPublicPromptFromFirestoreDocument(document: FirestoreDocument) { + return toPublicPrompt(getFirestoreDocumentId(document.name), { + title: getFirestoreStringField(document, "title"), + prompt: getFirestoreStringField(document, "prompt"), + slug: getFirestoreStringField(document, "slug"), + trendScore: getFirestoreNumberField(document, "trendScore"), + createdAt: getFirestoreTimestampField(document, "createdAt"), + updatedAt: getFirestoreTimestampField(document, "updatedAt"), + shareEnabled: getFirestoreBooleanField(document, "shareEnabled"), + }); +} + +function isPermissionError(error: unknown) { + return ( + error instanceof Error && + /insufficient permissions|permission[- ]denied/i.test(error.message) + ); +} + +async function listPublicPromptsViaRest( + category: PublicPromptSort, +): Promise { + const queryUrl = buildFirestoreRestUrl(":runQuery"); + + if (!queryUrl) { + throw new Error("Prompt catalog project is not configured."); + } + + const sortField = category === "trending" ? "trendScore" : "createdAt"; + const response = await fetch(queryUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + structuredQuery: { + from: [{ collectionId: PROMPT_CATALOG_COLLECTION }], + orderBy: [ + { + field: { fieldPath: sortField }, + direction: "DESCENDING", + }, + ], + limit: PUBLIC_PROMPT_LIMIT, + }, + }), + }); + + if (!response.ok) { + let errorMessage = `Failed to fetch public prompts (${response.status}).`; + + try { + const errorPayload = (await response.json()) as { + error?: { message?: string }; + }; + if (typeof errorPayload.error?.message === "string") { + errorMessage = errorPayload.error.message; + } + } catch { + // Keep the HTTP status fallback when Firestore does not return JSON. + } + + throw new Error(errorMessage); + } + + const queryResults = (await response.json()) as Array<{ + document?: FirestoreDocument; + }>; + + return queryResults + .map((result) => + result.document + ? toPublicPromptFromFirestoreDocument(result.document) + : null, + ) + .filter((prompt): prompt is PublicPrompt => !!prompt); +} + +async function listPublicPromptsViaFirestore( + category: PublicPromptSort, +): Promise { + const sortField = category === "trending" ? "trendScore" : "createdAt"; + const promptQuery = query( + collection(getFirestoreDb(), PROMPT_CATALOG_COLLECTION), + orderBy(sortField, "desc"), + limit(PUBLIC_PROMPT_LIMIT), + ); + const snapshot = await getDocs(promptQuery); + const prompts: PublicPrompt[] = []; + + snapshot.forEach((promptDoc) => { + const prompt = toPublicPrompt( + promptDoc.id, + promptDoc.data() as PromptCatalogDocument, + ); + + if (prompt) { + prompts.push(prompt); + } + }); + + return prompts; +} + +export const PromptGalleryManager = { + async listPublicPrompts(category: PublicPromptSort): Promise { + assertFirebaseConfigured(); + + try { + return await listPublicPromptsViaRest(category); + } catch (restError) { + console.warn( + "Prompt gallery: public catalog REST fetch failed, falling back to Firestore Lite.", + restError, + ); + } + + try { + return await listPublicPromptsViaFirestore(category); + } catch (firestoreError) { + if (isPermissionError(firestoreError)) { + throw new Error( + "Public prompts could not be loaded. Confirm the deployed Firestore rules allow reads on `prompt_catalog`.", + ); + } + + throw firestoreError; + } + }, + + async listSavedPrompts(): Promise { + assertFirebaseConfigured(); + + const userId = await requireSignedInUser("view saved prompts"); + const savedQuery = query( + getSavedPromptsCollection(userId), + orderBy("savedAt", "desc"), + ); + const snapshot = await getDocs(savedQuery); + const prompts: SavedPrompt[] = []; + + snapshot.forEach((promptDoc) => { + const prompt = toSavedPrompt( + promptDoc.id, + promptDoc.data() as SavedPromptDocument, + ); + + if (prompt) { + prompts.push(prompt); + } + }); + + return prompts; + }, + + async createCustomPrompt(input: { + title: string; + prompt: string; + }): Promise { + assertFirebaseConfigured(); + + const userId = await requireSignedInUser("save prompts"); + const title = normalizeText(input.title, "Title"); + const promptText = normalizeText(input.prompt, "Prompt"); + const savedPromptsCollection = getSavedPromptsCollection(userId); + const promptDoc = doc(savedPromptsCollection); + + await setDoc(promptDoc, { + title, + prompt: promptText, + sourceType: "custom", + catalogPromptId: null, + catalogSlug: null, + savedAt: serverTimestamp(), + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + }); + + const snapshot = await getDoc(promptDoc); + const savedPrompt = toSavedPrompt( + snapshot.id, + snapshot.data() as SavedPromptDocument, + ); + + if (!savedPrompt) { + throw new Error("Saved prompt could not be loaded."); + } + + return savedPrompt; + }, + + async updateSavedPrompt(input: { + id: string; + title: string; + prompt: string; + }): Promise { + assertFirebaseConfigured(); + + const userId = await requireSignedInUser("edit saved prompts"); + const savedPromptId = normalizeText(input.id, "Prompt id"); + const title = normalizeText(input.title, "Title"); + const promptText = normalizeText(input.prompt, "Prompt"); + const savedPromptRef = getSavedPromptRef(userId, savedPromptId); + const existingSnapshot = await getDoc(savedPromptRef); + + if (!existingSnapshot.exists()) { + throw new Error("Saved prompt could not be found."); + } + + const existingPrompt = toSavedPrompt( + existingSnapshot.id, + existingSnapshot.data() as SavedPromptDocument, + ); + + if (!existingPrompt) { + throw new Error("Saved prompt could not be loaded."); + } + + if (existingPrompt.sourceType !== "custom") { + throw new Error("Only private prompts can be edited right now."); + } + + await setDoc( + savedPromptRef, + { + title, + prompt: promptText, + updatedAt: serverTimestamp(), + }, + { merge: true }, + ); + + const snapshot = await getDoc(savedPromptRef); + const savedPrompt = toSavedPrompt( + snapshot.id, + snapshot.data() as SavedPromptDocument, + ); + + if (!savedPrompt) { + throw new Error("Saved prompt could not be loaded."); + } + + return savedPrompt; + }, + + async savePublicPrompt(prompt: PublicPrompt): Promise { + assertFirebaseConfigured(); + + const userId = await requireSignedInUser("save prompts"); + const title = normalizeText(prompt.title, "Title"); + const promptText = normalizeText(prompt.prompt, "Prompt"); + const savedPromptId = `catalog_${prompt.id}`; + const savedPromptRef = getSavedPromptRef(userId, savedPromptId); + const existingSnapshot = await getDoc(savedPromptRef); + const timestampFields = existingSnapshot.exists() + ? { + savedAt: serverTimestamp(), + updatedAt: serverTimestamp(), + } + : { + savedAt: serverTimestamp(), + createdAt: serverTimestamp(), + updatedAt: serverTimestamp(), + }; + + await setDoc( + savedPromptRef, + { + title, + prompt: promptText, + sourceType: "catalog", + catalogPromptId: prompt.id, + catalogSlug: normalizeText(prompt.slug, "Share link"), + ...timestampFields, + }, + { merge: true }, + ); + + const snapshot = await getDoc(savedPromptRef); + const savedPrompt = toSavedPrompt( + snapshot.id, + snapshot.data() as SavedPromptDocument, + ); + + if (!savedPrompt) { + throw new Error("Saved prompt could not be loaded."); + } + + return savedPrompt; + }, + + async deleteSavedPrompt(savedPromptId: string): Promise { + assertFirebaseConfigured(); + + const userId = await requireSignedInUser("remove saved prompts"); + const nextSavedPromptId = normalizeText(savedPromptId, "Prompt id"); + await deleteDoc(getSavedPromptRef(userId, nextSavedPromptId)); + }, +}; diff --git a/apps/extension/src/background/router.ts b/apps/extension/src/background/router.ts index 04c636e..e1eaf9d 100644 --- a/apps/extension/src/background/router.ts +++ b/apps/extension/src/background/router.ts @@ -6,6 +6,7 @@ import { EntityExtractor, } from "@plenz/core"; import { AuthManager } from "./auth"; +import { PromptGalleryManager } from "./prompt-gallery"; export async function handleMessage( message: any, @@ -131,6 +132,55 @@ export async function handleMessage( case "GET_AUTH_STATUS": return await AuthManager.getAuthStatus(); + case "OPEN_PROMPT_GALLERY_PANEL": { + const windowId = message.payload?.windowId; + + if (typeof windowId !== "number") { + return { success: false, error: "Current window is unavailable." }; + } + + await chrome.sidePanel.open({ windowId }); + return { success: true }; + } + + case "LIST_PUBLIC_PROMPTS": + return { + prompts: await PromptGalleryManager.listPublicPrompts( + message.payload?.category === "newest" ? "newest" : "trending", + ), + }; + + case "LIST_SAVED_PROMPTS": + return { + prompts: await PromptGalleryManager.listSavedPrompts(), + }; + + case "CREATE_SAVED_PROMPT": + return { + prompt: await PromptGalleryManager.createCustomPrompt({ + title: message.payload?.title ?? "", + prompt: message.payload?.prompt ?? "", + }), + }; + + case "UPDATE_SAVED_PROMPT": + return { + prompt: await PromptGalleryManager.updateSavedPrompt({ + id: message.payload?.id ?? "", + title: message.payload?.title ?? "", + prompt: message.payload?.prompt ?? "", + }), + }; + + case "SAVE_PUBLIC_PROMPT": + return { + prompt: await PromptGalleryManager.savePublicPrompt(message.payload?.prompt), + }; + + case "DELETE_SAVED_PROMPT": + await PromptGalleryManager.deleteSavedPrompt(message.payload?.id ?? ""); + return { success: true }; + default: console.warn("Unknown message type:", message.type); return null; diff --git a/apps/extension/src/background/storage-backend.ts b/apps/extension/src/background/storage-backend.ts index 4cac0bf..d57b9f2 100644 --- a/apps/extension/src/background/storage-backend.ts +++ b/apps/extension/src/background/storage-backend.ts @@ -18,11 +18,10 @@ import { type FirestoreProviderConfig, } from "./encryption"; import { - getFirebaseAuth, getFirestoreDb, isFirebaseConfigured, - signInToFirebase, } from "./firebase"; +import { getFirebaseUserId } from "./firebase-user"; export const MODEL_CONFIG_KEY_PREFIX = "model_config_"; export type StorageBackendPreference = "chrome-sync" | "firebase"; @@ -252,34 +251,12 @@ export class ChromeSyncBackend implements StorageBackend { export class FirebaseBackend implements StorageBackend { private readonly syncFallback = new ChromeSyncBackend(); - private async getFirebaseUserId() { - if (!isFirebaseConfigured()) { - throw new Error( - "Firebase is not configured in this build. Add the VITE_FIREBASE_* environment variables before enabling Cloud Sync.", - ); - } - - const firebaseAuth = getFirebaseAuth(); - const cachedUser = await AuthManager.getCachedUser(); - if ( - firebaseAuth.currentUser && - cachedUser?.email && - firebaseAuth.currentUser.email === cachedUser.email - ) { - return firebaseAuth.currentUser.uid; - } - - const token = await AuthManager.getAuthToken(false); - const credential = await signInToFirebase(token); - return credential.user.uid; - } - private async withFirebaseFallback( operation: (userId: string) => Promise>, fallback: () => Promise>, ): Promise> { try { - const userId = await this.getFirebaseUserId(); + const userId = await getFirebaseUserId(); return await operation(userId); } catch (error) { return appendFallbackMessage(await fallback(), error); diff --git a/apps/extension/src/popup/App.tsx b/apps/extension/src/popup/App.tsx index 5d74c41..6a847a0 100644 --- a/apps/extension/src/popup/App.tsx +++ b/apps/extension/src/popup/App.tsx @@ -3,7 +3,7 @@ import logo from "../assets/logo.svg"; import { providers } from "@plenz/providers"; import { AuthStatus } from "../components/AuthStatus"; import type { ModelOption } from "@plenz/types"; -import { BarChart3, Settings, Users } from "lucide-react"; +import { BarChart3, BookOpen, Settings, Users } from "lucide-react"; import { Badge } from "@plenz/ui/components/badge"; import { Button } from "@plenz/ui/components/button"; import { @@ -34,6 +34,7 @@ export function App() { modelId: string | null; } | null>(null); const [providerModels, setProviderModels] = useState>({}); + const [openingPromptGallery, setOpeningPromptGallery] = useState(false); useEffect(() => { fetchActiveModel(); @@ -90,6 +91,36 @@ export function App() { void chrome.tabs.create({ url }); }; + const openPromptGallery = async () => { + if (openingPromptGallery) { + return; + } + + setOpeningPromptGallery(true); + + try { + const currentWindow = await chrome.windows.getCurrent(); + + if (typeof currentWindow.id !== "number") { + throw new Error("Current browser window is unavailable."); + } + + const response = (await chrome.runtime.sendMessage({ + type: "OPEN_PROMPT_GALLERY_PANEL", + payload: { windowId: currentWindow.id }, + })) as { success?: boolean; error?: string } | undefined; + + if (!response?.success) { + throw new Error(response?.error || "Prompt gallery could not be opened."); + } + + window.close(); + } catch (error) { + console.error("Popup: failed to open prompt gallery", error); + setOpeningPromptGallery(false); + } + }; + const handleModelChange = (newModelId: string) => { if (!newModelId) return; @@ -206,6 +237,26 @@ export function App() { ) : null} + +
+

+ Prompt gallery +

+ +

+ Browse trending prompts, review newly added ideas, and keep your + saved prompts in one side panel. +

+
+ +

Links diff --git a/apps/extension/src/sidepanel/App.tsx b/apps/extension/src/sidepanel/App.tsx new file mode 100644 index 0000000..7bbc7c2 --- /dev/null +++ b/apps/extension/src/sidepanel/App.tsx @@ -0,0 +1,55 @@ +import { Card } from "@plenz/ui/components/card" +import { Toaster } from "sonner" +import { PromptGalleryProvider } from "./PromptGalleryProvider" +import { + PromptGalleryContent, + PromptGalleryHeader, + PromptGallerySaveDialog, + PromptGalleryToasts, + PromptGalleryToolbar, +} from "./PromptGallerySections" + +const PROMPT_GALLERY_TOAST_MAX_WIDTH = 356 + +export function App() { + return ( + + + + ) +} + +function PromptGalleryScreen() { + return ( +

+
+ + +
+ + + + + +
+
+ + + + +
+ ) +} diff --git a/apps/extension/src/sidepanel/PromptGalleryProvider.tsx b/apps/extension/src/sidepanel/PromptGalleryProvider.tsx new file mode 100644 index 0000000..d8543a1 --- /dev/null +++ b/apps/extension/src/sidepanel/PromptGalleryProvider.tsx @@ -0,0 +1,547 @@ +import type { PublicPrompt, SavedPrompt } from "@plenz/types" +import { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from "react" +import { promptGalleryRuntime } from "./prompt-gallery.runtime" +import type { + ActionState, + GalleryFilter, + PromptGalleryContextValue, + PromptGalleryState, + PromptDraftMode, + PromptQueryState, +} from "./prompt-gallery.types" +import { + buildCatalogSaveLookup, + createPromptQueryState, + getCatalogShareSlug, + getSearchablePromptCollection, + isUserInfo, + matchesSearch, + PUBLIC_SHARE_BASE_URL, +} from "./prompt-gallery.utils" +import type { UserInfo } from "../background/auth" + +const initialState: PromptGalleryState = { + activeFilter: "trending", + actionState: null, + authLoading: true, + busyPromptId: null, + draft: { + savedPromptId: null, + prompt: "", + title: "", + }, + expandedPromptIds: {}, + promptDraftMode: "create", + promptModalOpen: false, + publicNewestPrompts: createPromptQueryState("loading"), + publicTrendingPrompts: createPromptQueryState("loading"), + savedPrompts: createPromptQueryState("idle"), + submittingPromptDraft: false, + searchTerm: "", + user: null, +} + +const PromptGalleryContext = createContext(null) + +export function PromptGalleryProvider({ + children, +}: { + children: ReactNode +}) { + const [state, setState] = useState(initialState) + + useEffect(() => { + void loadAuthStatus() + void Promise.all([loadPublicPrompts("trending"), loadPublicPrompts("newest")]) + }, []) + + useEffect(() => { + const handleAuthChange = (event: Event) => { + const detail = (event as CustomEvent<{ user: UserInfo | null }>).detail + const nextUser = detail?.user ?? null + + setState((current) => ({ + ...current, + authLoading: false, + savedPrompts: nextUser + ? current.savedPrompts + : createPromptQueryState("idle"), + user: nextUser, + })) + + if (nextUser) { + void loadSavedPrompts() + } + } + + window.addEventListener( + "plenz-auth-status-changed", + handleAuthChange as EventListener, + ) + + return () => { + window.removeEventListener( + "plenz-auth-status-changed", + handleAuthChange as EventListener, + ) + } + }, []) + + async function loadAuthStatus() { + try { + const response = await promptGalleryRuntime.getAuthStatus() + + setState((current) => ({ + ...current, + authLoading: false, + user: isUserInfo(response) ? response : null, + })) + + if (isUserInfo(response)) { + await loadSavedPrompts() + } + } catch (error) { + console.error("Prompt gallery: failed to read auth status", error) + + setState((current) => ({ + ...current, + authLoading: false, + user: null, + })) + } + } + + async function loadPublicPrompts(category: "trending" | "newest") { + const stateKey = + category === "trending" ? "publicTrendingPrompts" : "publicNewestPrompts" + + setState((current) => ({ + ...current, + [stateKey]: { + ...current[stateKey], + error: null, + status: "loading", + }, + })) + + try { + const response = await promptGalleryRuntime.listPublicPrompts(category) + + setState((current) => ({ + ...current, + [stateKey]: { + data: response.prompts, + error: null, + status: "ready", + } satisfies PromptQueryState, + })) + } catch (error) { + setState((current) => ({ + ...current, + [stateKey]: { + data: [], + error: (error as Error).message, + status: "error", + } satisfies PromptQueryState, + })) + } + } + + async function loadSavedPrompts() { + setState((current) => ({ + ...current, + savedPrompts: { + ...current.savedPrompts, + error: null, + status: "loading", + }, + })) + + try { + const response = await promptGalleryRuntime.listSavedPrompts() + + setState((current) => ({ + ...current, + savedPrompts: { + data: response.prompts, + error: null, + status: "ready", + }, + })) + } catch (error) { + setState((current) => ({ + ...current, + savedPrompts: { + data: [], + error: (error as Error).message, + status: "error", + }, + })) + } + } + + function setActionState(actionState: ActionState | null) { + setState((current) => ({ + ...current, + actionState, + })) + } + + function clearActionState() { + setActionState(null) + } + + function setSearchTerm(value: string) { + setState((current) => ({ + ...current, + searchTerm: value, + })) + } + + function setActiveFilter(filter: GalleryFilter) { + setState((current) => ({ + ...current, + activeFilter: filter, + })) + } + + function togglePromptExpanded(promptId: string) { + setState((current) => ({ + ...current, + expandedPromptIds: { + ...current.expandedPromptIds, + [promptId]: !current.expandedPromptIds[promptId], + }, + })) + } + + function openPromptModal(mode: PromptDraftMode, prompt?: SavedPrompt) { + setState((current) => { + if (!current.user) { + return { + ...current, + actionState: { + kind: "error", + title: "Sign in required", + message: "Sign in with Google to save your own prompts.", + }, + activeFilter: "saved", + } + } + + if (mode === "edit") { + if (!prompt || prompt.sourceType !== "custom") { + return { + ...current, + actionState: { + kind: "error", + title: "Only private prompts can be edited right now.", + message: "Only private prompts can be edited right now.", + }, + } + } + + return { + ...current, + draft: { + savedPromptId: prompt.id, + prompt: prompt.prompt, + title: prompt.title, + }, + promptDraftMode: "edit", + promptModalOpen: true, + } + } + + return { + ...current, + draft: { + savedPromptId: null, + prompt: "", + title: "", + }, + promptDraftMode: "create", + promptModalOpen: true, + } + }) + } + + function openCreatePromptModal() { + openPromptModal("create") + } + + function openEditSavedPromptModal(prompt: SavedPrompt) { + openPromptModal("edit", prompt) + } + + function closePromptModal() { + setState((current) => ({ + ...current, + draft: { + savedPromptId: null, + prompt: "", + title: "", + }, + promptDraftMode: "create", + promptModalOpen: false, + })) + } + + function setDraftTitle(value: string) { + setState((current) => ({ + ...current, + draft: { + ...current.draft, + title: value, + }, + })) + } + + function setDraftPrompt(value: string) { + setState((current) => ({ + ...current, + draft: { + ...current.draft, + prompt: value, + }, + })) + } + + async function copyPrompt(prompt: PublicPrompt | SavedPrompt) { + try { + await navigator.clipboard.writeText(prompt.prompt) + setActionState({ + kind: "success", + title: "Prompt copied", + }) + } catch (error) { + setActionState({ + kind: "error", + title: "Prompt copy failed", + message: (error as Error).message || "Prompt could not be copied.", + }) + } + } + + async function sharePrompt(prompt: PublicPrompt | SavedPrompt) { + const shareSlug = getCatalogShareSlug(prompt) + + if (!prompt.canShare || !shareSlug) { + setActionState({ + kind: "error", + title: "Prompt share failed", + message: "Only editorial prompts can be shared right now.", + }) + return + } + + try { + await navigator.clipboard.writeText(`${PUBLIC_SHARE_BASE_URL}/${shareSlug}`) + setActionState({ + kind: "success", + title: "Prompt shared", + message: `Copied a share link for "${prompt.title}".`, + }) + } catch (error) { + setActionState({ + kind: "error", + title: "Prompt share failed", + message: (error as Error).message || "Share link could not be copied.", + }) + } + } + + async function toggleCatalogSave(prompt: PublicPrompt) { + const savedCatalogPrompts = buildCatalogSaveLookup(state.savedPrompts.data) + const existingSavedPrompt = savedCatalogPrompts[prompt.id] + + setState((current) => ({ + ...current, + busyPromptId: prompt.id, + })) + + try { + if (existingSavedPrompt) { + await promptGalleryRuntime.deleteSavedPrompt(existingSavedPrompt.id) + setActionState({ + kind: "success", + title: "Prompt removed", + // message: `Removed "${prompt.title}" from your saved prompts.`, + }) + } else { + await promptGalleryRuntime.savePublicPrompt(prompt) + setActionState({ + kind: "success", + title: "Prompt saved", + message: `Saved "${prompt.title}" to your prompt library.`, + }) + } + + await loadSavedPrompts() + } catch (error) { + setActionState({ + kind: "error", + title: "Prompt update failed", + message: (error as Error).message || "Prompt could not be updated.", + }) + } finally { + setState((current) => ({ + ...current, + busyPromptId: null, + })) + } + } + + async function deleteSavedPrompt(prompt: SavedPrompt) { + setState((current) => ({ + ...current, + busyPromptId: prompt.id, + })) + + try { + await promptGalleryRuntime.deleteSavedPrompt(prompt.id) + setActionState({ + kind: "success", + title: "Prompt removed", + // message: `Removed "${prompt.title}" from your saved prompts.`, + }) + await loadSavedPrompts() + } catch (error) { + setActionState({ + kind: "error", + title: "Prompt removal failed", + message: (error as Error).message || "Prompt could not be removed.", + }) + } finally { + setState((current) => ({ + ...current, + busyPromptId: null, + })) + } + } + + async function submitPromptDraft() { + if (state.submittingPromptDraft) { + return + } + + setState((current) => ({ + ...current, + submittingPromptDraft: true, + })) + + try { + const isEditing = state.promptDraftMode === "edit" + const actionLabel = isEditing ? "updated" : "saved" + + if (isEditing) { + await promptGalleryRuntime.updateSavedPrompt( + state.draft.savedPromptId ?? "", + state.draft.title, + state.draft.prompt, + ) + } else { + await promptGalleryRuntime.createSavedPrompt( + state.draft.title, + state.draft.prompt, + ) + } + + setState((current) => ({ + ...current, + actionState: { + kind: "success", + title: `Prompt ${actionLabel}`, + // message: `Private prompt ${actionLabel} in your library.`, + }, + activeFilter: "saved", + draft: { + savedPromptId: null, + prompt: "", + title: "", + }, + promptDraftMode: "create", + promptModalOpen: false, + })) + + await loadSavedPrompts() + } catch (error) { + setActionState({ + kind: "error", + title: "Prompt save failed", + message: (error as Error).message || "Prompt could not be saved.", + }) + } finally { + setState((current) => ({ + ...current, + submittingPromptDraft: false, + })) + } + } + + const selectedPrompts = getSearchablePromptCollection( + state.activeFilter, + state.publicTrendingPrompts.data, + state.publicNewestPrompts.data, + state.savedPrompts.data, + ).filter((prompt) => matchesSearch(prompt, state.searchTerm)) + + const savedCatalogPrompts = buildCatalogSaveLookup(state.savedPrompts.data) + const activeQueryState = + state.activeFilter === "trending" + ? state.publicTrendingPrompts + : state.activeFilter === "newest" + ? state.publicNewestPrompts + : state.savedPrompts + const isSavedViewLocked = + state.activeFilter === "saved" && !state.authLoading && !state.user + + const value: PromptGalleryContextValue = { + actions: { + clearActionState, + closePromptModal, + copyPrompt, + deleteSavedPrompt, + openCreatePromptModal, + openEditSavedPromptModal, + submitPromptDraft, + setActiveFilter, + setDraftPrompt, + setDraftTitle, + setSearchTerm, + sharePrompt, + toggleCatalogSave, + togglePromptExpanded, + }, + state, + view: { + activeError: activeQueryState.error, + activeLoading: activeQueryState.status === "loading", + isSavedViewLocked, + savedCatalogPrompts, + selectedPrompts, + }, + } + + return ( + + {children} + + ) +} + +export function usePromptGallery() { + const context = useContext(PromptGalleryContext) + + if (!context) { + throw new Error("usePromptGallery must be used within PromptGalleryProvider.") + } + + return context +} diff --git a/apps/extension/src/sidepanel/PromptGallerySections.tsx b/apps/extension/src/sidepanel/PromptGallerySections.tsx new file mode 100644 index 0000000..a9b5894 --- /dev/null +++ b/apps/extension/src/sidepanel/PromptGallerySections.tsx @@ -0,0 +1,515 @@ +import type { PublicPrompt, SavedPrompt } from "@plenz/types" +import { useEffect, useRef } from "react" +import { AuthStatus } from "../components/AuthStatus" +import { Badge } from "@plenz/ui/components/badge" +import { Button } from "@plenz/ui/components/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@plenz/ui/components/card" +import { Input } from "@plenz/ui/components/input" +import { Skeleton } from "@plenz/ui/components/skeleton" +import { + CalendarIcon, + ChevronDownIcon, + ChevronUpIcon, + CopyIcon, + Plus, + Search, + SquarePenIcon, + Trash2Icon, +} from "lucide-react" +import { toast } from "sonner" +import { usePromptGallery } from "./PromptGalleryProvider" +import type { GalleryFilter, PromptGalleryActions } from "./prompt-gallery.types" +import { + formatDateLabel, + formatPromptPreview, + getCatalogSourceLabel, + isPublicPrompt, +} from "./prompt-gallery.utils" +import { cn } from "@plenz/ui/index" + +const FILTER_OPTIONS: Array<{ + id: GalleryFilter + label: string +}> = [ + { id: "trending", label: "Trending" }, + { id: "newest", label: "Newly added" }, + { id: "saved", label: "Saved" }, + ] + +export function PromptGalleryHeader() { + const { actions } = usePromptGallery() + + return ( + + +
+

+ Prompt gallery +

+ +
+
+ + + +
+ ) +} + +export function PromptGalleryToolbar() { + const { actions, state } = usePromptGallery() + + return ( + +
+ + + actions.setSearchTerm((event.target as HTMLInputElement).value) + } + placeholder="Search by title or prompt text" + className="pl-9" + /> +
+ +
+ {FILTER_OPTIONS.map((filterOption) => ( + + ))} +
+
+ ) +} + +function PromptGalleryFilterButton({ + filter, + label, +}: { + filter: GalleryFilter + label: string +}) { + const { actions, state } = usePromptGallery() + const count = + filter === "trending" + ? state.publicTrendingPrompts.data.length + : filter === "newest" + ? state.publicNewestPrompts.data.length + : state.savedPrompts.data.length + + return ( + + ) +} + +export function PromptGalleryToasts() { + const { actions, state } = usePromptGallery() + const lastToastKeyRef = useRef(null) + + useEffect(() => { + if (!state.actionState) { + lastToastKeyRef.current = null + return + } + + const toastKey = `${state.actionState.kind}:${state.actionState.title}:${state.actionState.message}` + + if (lastToastKeyRef.current === toastKey) { + return + } + + lastToastKeyRef.current = toastKey + + if (state.actionState.kind === "success") { + toast.success(state.actionState.title, { description: state.actionState.message }) + } else { + toast.error(state.actionState.title, { description: state.actionState.message }) + } + + actions.clearActionState() + }, [actions, state.actionState]) + + return null +} + +export function PromptGalleryContent() { + const { state, view } = usePromptGallery() + + if (view.activeLoading) { + return + } + + if (view.activeError) { + return ( + + ) + } + + if (view.isSavedViewLocked) { + return ( + + ) + } + + if (view.selectedPrompts.length === 0) { + return ( + + ) + } + + return ( +
+ {view.selectedPrompts.map((prompt) => ( + + ))} +
+ ) +} + +function PromptGalleryLoadingState() { + return ( +
+ + + +
+ ) +} + +function PromptGalleryMessageCard({ + description, + title, + tone = "default", +}: { + description: string + title: string + tone?: "default" | "error" +}) { + return ( + + +

+ {title} +

+

+ {description} +

+
+
+ ) +} + +function PromptGalleryPromptCard({ + prompt, +}: { + prompt: PublicPrompt | SavedPrompt +}) { + const { actions, state, view } = usePromptGallery() + const expanded = !!state.expandedPromptIds[prompt.id] + const promptText = formatPromptPreview(prompt.prompt, expanded) + const isExpandable = prompt.prompt.length > 240 + + return ( + + + +
+

+ {prompt.title} +

+ +
+
+ + {formatDateLabel( + "savedAt" in prompt + ? prompt.savedAt || prompt.updatedAt + : prompt.createdAt || prompt.updatedAt, + )} +
+
+
+ + isExpandable && actions.togglePromptExpanded(prompt.id)} + > +

+ {promptText} +

+ +
+ {isExpandable ? ( + expanded ? () : () + ) : null} +
+
+
+ ) +} + +function PromptGalleryPromptActions({ + prompt, actions +}: { + prompt: PublicPrompt | SavedPrompt + actions: PromptGalleryActions +}) { + return ( +
+ {isPublicPrompt(prompt) ? ( + + ) : ( + + )} + + {/* */} + + +
+ ) + +} + +function PromptGalleryPromptBadges({ + prompt, +}: { + prompt: PublicPrompt | SavedPrompt +}) { + return ( +
+ + {getCatalogSourceLabel(prompt)} + + + {"savedAt" in prompt && prompt.sourceType === "custom" ? ( + + Private + + ) : null} +
+ ) +} + +function CatalogPromptActions({ + prompt, +}: { + prompt: PublicPrompt +}) { + const { actions, state, view } = usePromptGallery() + const savedPrompt = view.savedCatalogPrompts[prompt.id] + const buttonLabel = + state.busyPromptId === prompt.id + ? "Working..." + : savedPrompt + ? "Saved" + : "Save" + + return ( + + ) +} + +function SavedPromptActions({ + prompt, +}: { + prompt: SavedPrompt +}) { + const { actions, state } = usePromptGallery() + const isBusy = state.busyPromptId === prompt.id + + return ( + <> + + + {prompt.sourceType === "custom" ? ( + + ) : null} + + ) +} + +export function PromptGallerySaveDialog() { + const { actions, state } = usePromptGallery() + const isEditing = state.promptDraftMode === "edit" + + if (!state.promptModalOpen) { + return null + } + + return ( +
+ + +

+ {isEditing ? "Edit a private prompt" : "Save a private prompt"} +

+ + {isEditing + ? "Update a prompt in your saved library" + : "Add a prompt to your saved library"} + + + Saved prompts are private to your Google account and appear only + under your Saved filter. + +
+ + +
+ + + actions.setDraftTitle( + (event.target as HTMLInputElement).value, + ) + } + placeholder="Example: Launch announcement prompt" + /> +
+ +
+ +