feat: editor feedback
This commit is contained in:
parent
b68bf00454
commit
78a0a27a72
|
|
@ -0,0 +1,31 @@
|
|||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { submitFeedback, MAX_MESSAGE_LENGTH } from "@/lib/feedback";
|
||||
|
||||
const submitSchema = z.object({
|
||||
message: z
|
||||
.string()
|
||||
.min(1, "Message is required")
|
||||
.max(MAX_MESSAGE_LENGTH, "Message too long"),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { limited } = await checkRateLimit({ request });
|
||||
if (limited) {
|
||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const result = submitSchema.safeParse(body);
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid input", details: result.error.flatten().fieldErrors },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const entry = await submitFeedback(result.data);
|
||||
return NextResponse.json({ entry }, { status: 201 });
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import { DeleteProjectDialog } from "./dialogs/delete-project-dialog";
|
|||
import { useRouter } from "next/navigation";
|
||||
import { FaDiscord } from "react-icons/fa6";
|
||||
import { ExportButton } from "./export-button";
|
||||
import { FeedbackPopover } from "@/lib/feedback/components/feedback-popover";
|
||||
import { ThemeToggle } from "../theme-toggle";
|
||||
import { DEFAULT_LOGO_URL } from "@/lib/site/brand";
|
||||
import { SOCIAL_LINKS } from "@/lib/site/social";
|
||||
|
|
@ -34,6 +35,7 @@ export function EditorHeader() {
|
|||
<EditableProjectName />
|
||||
</div>
|
||||
<nav className="flex items-center gap-2">
|
||||
<FeedbackPopover />
|
||||
<ExportButton />
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -48,6 +48,14 @@ export const accounts = pgTable("accounts", {
|
|||
updatedAt: timestamp("updated_at").notNull(),
|
||||
}).enableRLS();
|
||||
|
||||
export const feedback = pgTable("feedback", {
|
||||
id: text("id").primaryKey(),
|
||||
message: text("message").notNull(),
|
||||
createdAt: timestamp("created_at")
|
||||
.$defaultFn(() => new Date())
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const verifications = pgTable("verifications", {
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,202 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormControl,
|
||||
clearFormDraft,
|
||||
} from "@/components/ui/form";
|
||||
import type { FeedbackEntry } from "../types";
|
||||
|
||||
const PERSIST_KEY = "feedback-draft";
|
||||
const HISTORY_KEY = "feedback-history";
|
||||
const MAX_HISTORY = 20;
|
||||
|
||||
interface FeedbackFormValues {
|
||||
message: string;
|
||||
}
|
||||
|
||||
function readHistory(): FeedbackEntry[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(HISTORY_KEY);
|
||||
return stored ? (JSON.parse(stored) as FeedbackEntry[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeHistory({ entries }: { entries: FeedbackEntry[] }): void {
|
||||
try {
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(entries));
|
||||
} catch {
|
||||
// localStorage may be full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function useFeedback() {
|
||||
const [entries, setEntries] = useState<FeedbackEntry[]>(readHistory);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
async function submit({
|
||||
values,
|
||||
onSuccess,
|
||||
}: {
|
||||
values: FeedbackFormValues;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
if (isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
throw new Error(data?.error ?? "Failed to submit");
|
||||
}
|
||||
|
||||
const { entry } = await res.json();
|
||||
const next = [entry, ...entries].slice(0, MAX_HISTORY);
|
||||
setEntries(next);
|
||||
writeHistory({ entries: next });
|
||||
onSuccess();
|
||||
toast.success("Feedback sent");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to send feedback",
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return { entries, isSubmitting, submit };
|
||||
}
|
||||
|
||||
export function FeedbackPopover() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="h-8">
|
||||
Send feedback
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80 p-0">
|
||||
<FeedbackPopoverContent onClose={() => setOpen(false)} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackPopoverContent({ onClose }: { onClose: () => void }) {
|
||||
const { entries, isSubmitting, submit } = useFeedback();
|
||||
|
||||
const form = useForm<FeedbackFormValues>({
|
||||
defaultValues: { message: "" },
|
||||
});
|
||||
|
||||
async function handleSubmit(values: FeedbackFormValues) {
|
||||
await submit({
|
||||
values,
|
||||
onSuccess: () => {
|
||||
form.reset({ message: "" });
|
||||
clearFormDraft({ key: PERSIST_KEY });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Form persistKey={PERSIST_KEY} {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="flex flex-col">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="message"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Thoughts, bugs, ideas..."
|
||||
className="min-h-[7rem] text-sm p-3 bg-background shadow-none border-none! resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end border-t px-3 py-2 gap-2">
|
||||
{!form.watch("message").trim() && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={isSubmitting || !form.watch("message").trim()}
|
||||
>
|
||||
{isSubmitting ? <Spinner /> : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{entries.length > 0 && (
|
||||
<div className="border-t">
|
||||
<div className="px-3 py-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Previous feedback
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto px-3 pb-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
{entries.map((entry) => (
|
||||
<FeedbackEntryItem key={entry.id} entry={entry} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackEntryItem({ entry }: { entry: FeedbackEntry }) {
|
||||
const formatted = new Date(entry.createdAt).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-md border px-2.5 py-2">
|
||||
<p className="text-sm whitespace-pre-wrap break-words">{entry.message}</p>
|
||||
<span className="mt-1 block text-xs text-muted-foreground">
|
||||
{formatted}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { submitFeedback } from "./queries";
|
||||
export { MAX_MESSAGE_LENGTH } from "./types";
|
||||
export type { FeedbackEntry, SubmitFeedbackInput } from "./types";
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { db, feedback } from "@/lib/db";
|
||||
import { generateUUID } from "@/utils/id";
|
||||
import type { FeedbackEntry, SubmitFeedbackInput } from "./types";
|
||||
|
||||
export async function submitFeedback({
|
||||
message,
|
||||
}: SubmitFeedbackInput): Promise<FeedbackEntry> {
|
||||
const id = generateUUID();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(feedback).values({ id, message, createdAt: now });
|
||||
|
||||
return { id, message, createdAt: now.toISOString() };
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
export const MAX_MESSAGE_LENGTH = 5000;
|
||||
|
||||
export interface FeedbackEntry {
|
||||
id: string;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SubmitFeedbackInput {
|
||||
message: string;
|
||||
}
|
||||
|
|
@ -1,317 +1,317 @@
|
|||
import {
|
||||
STICKER_CATEGORIES,
|
||||
} from "@/lib/stickers/categories";
|
||||
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/lib/stickers/intrinsic-size";
|
||||
import type { StickerCategory } from "@/lib/stickers/types";
|
||||
import { stickersRegistry } from "./registry";
|
||||
import { resolveStickerId } from "./resolver";
|
||||
import { registerDefaultStickerProviders } from "./providers";
|
||||
import { parseStickerId } from "./sticker-id";
|
||||
import type {
|
||||
StickerBrowseResult,
|
||||
StickerItem,
|
||||
StickerProvider,
|
||||
StickerSearchResult,
|
||||
} from "./types";
|
||||
|
||||
const DEFAULT_BROWSE_LIMIT = 12;
|
||||
const DEFAULT_SEARCH_LIMIT = 100;
|
||||
|
||||
function mergeSearchResults({
|
||||
results,
|
||||
}: {
|
||||
results: StickerSearchResult[];
|
||||
}): StickerSearchResult {
|
||||
const deduplicatedItems = new Map<
|
||||
string,
|
||||
StickerSearchResult["items"][number]
|
||||
>();
|
||||
let total = 0;
|
||||
let hasMore = false;
|
||||
|
||||
for (const result of results) {
|
||||
total += result.total;
|
||||
hasMore = hasMore || result.hasMore;
|
||||
for (const item of result.items) {
|
||||
if (!deduplicatedItems.has(item.id)) {
|
||||
deduplicatedItems.set(item.id, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items: Array.from(deduplicatedItems.values()),
|
||||
total,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
function getProviderByCategory({
|
||||
category,
|
||||
}: {
|
||||
category: StickerCategory;
|
||||
}): StickerProvider | null {
|
||||
if (category === "all") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return stickersRegistry.get(category);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getEmptyBrowseResult(): StickerBrowseResult {
|
||||
return {
|
||||
sections: [],
|
||||
};
|
||||
}
|
||||
|
||||
function getStickerNameFromId({ stickerId }: { stickerId: string }): string {
|
||||
const stickerIdParts = stickerId.split(":");
|
||||
if (stickerIdParts.length <= 1) {
|
||||
return stickerId;
|
||||
}
|
||||
return (
|
||||
stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ??
|
||||
stickerId
|
||||
);
|
||||
}
|
||||
|
||||
function toRecentStickerItem({
|
||||
stickerId,
|
||||
}: {
|
||||
stickerId: string;
|
||||
}): StickerItem | null {
|
||||
try {
|
||||
const { providerId } = parseStickerId({ stickerId });
|
||||
return {
|
||||
id: stickerId,
|
||||
provider: providerId,
|
||||
name: getStickerNameFromId({ stickerId }),
|
||||
previewUrl: resolveStickerId({
|
||||
stickerId,
|
||||
options: { width: 64, height: 64 },
|
||||
}),
|
||||
metadata: {},
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchStickers({
|
||||
query,
|
||||
category,
|
||||
limit = DEFAULT_SEARCH_LIMIT,
|
||||
}: {
|
||||
query: string;
|
||||
category: StickerCategory;
|
||||
limit?: number;
|
||||
}): Promise<StickerSearchResult> {
|
||||
registerDefaultStickerProviders({});
|
||||
|
||||
const effectiveCategory = category in STICKER_CATEGORIES ? category : "all";
|
||||
if (effectiveCategory !== "all") {
|
||||
const provider = getProviderByCategory({ category: effectiveCategory });
|
||||
if (!provider) {
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
return provider.search({
|
||||
query,
|
||||
options: { limit },
|
||||
});
|
||||
}
|
||||
|
||||
const providers = stickersRegistry.getAll();
|
||||
if (providers.length === 0) {
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
|
||||
const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length));
|
||||
const settledResults = await Promise.allSettled(
|
||||
providers.map((provider) =>
|
||||
provider.search({
|
||||
query,
|
||||
options: { limit: perProviderLimit },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const fulfilledResults = settledResults
|
||||
.filter(
|
||||
(result): result is PromiseFulfilledResult<StickerSearchResult> =>
|
||||
result.status === "fulfilled",
|
||||
)
|
||||
.map((result) => result.value);
|
||||
|
||||
return mergeSearchResults({
|
||||
results: fulfilledResults,
|
||||
});
|
||||
}
|
||||
|
||||
export async function searchAll({
|
||||
query,
|
||||
limit = DEFAULT_SEARCH_LIMIT,
|
||||
}: {
|
||||
query: string;
|
||||
limit?: number;
|
||||
}): Promise<StickerBrowseResult> {
|
||||
registerDefaultStickerProviders({});
|
||||
|
||||
const providers = stickersRegistry.getAll();
|
||||
if (providers.length === 0) {
|
||||
return { sections: [] };
|
||||
}
|
||||
|
||||
const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length));
|
||||
const settledResults = await Promise.allSettled(
|
||||
providers.map(async (provider) => {
|
||||
const result = await provider.search({
|
||||
query,
|
||||
options: { limit: perProviderLimit },
|
||||
});
|
||||
return { provider, result };
|
||||
}),
|
||||
);
|
||||
|
||||
const sections: StickerBrowseResult["sections"] = [];
|
||||
for (const settled of settledResults) {
|
||||
if (settled.status !== "fulfilled") {
|
||||
continue;
|
||||
}
|
||||
const { provider, result } = settled.value;
|
||||
if (result.items.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const category = provider.id as StickerCategory;
|
||||
sections.push({
|
||||
id: category,
|
||||
title: STICKER_CATEGORIES[category] ?? provider.id,
|
||||
items: result.items,
|
||||
hasMore: result.hasMore,
|
||||
layout: "grid",
|
||||
});
|
||||
}
|
||||
|
||||
return { sections };
|
||||
}
|
||||
|
||||
export async function browseCategory({
|
||||
category,
|
||||
}: {
|
||||
category: StickerCategory;
|
||||
}): Promise<StickerBrowseResult> {
|
||||
registerDefaultStickerProviders({});
|
||||
|
||||
const effectiveCategory = category in STICKER_CATEGORIES ? category : "all";
|
||||
if (effectiveCategory === "all") {
|
||||
return getEmptyBrowseResult();
|
||||
}
|
||||
|
||||
const provider = getProviderByCategory({ category: effectiveCategory });
|
||||
if (!provider) {
|
||||
return getEmptyBrowseResult();
|
||||
}
|
||||
|
||||
return provider.browse({ options: {} });
|
||||
}
|
||||
|
||||
export async function browseAll({
|
||||
recentStickers,
|
||||
limit = DEFAULT_BROWSE_LIMIT,
|
||||
}: {
|
||||
recentStickers: string[];
|
||||
limit?: number;
|
||||
}): Promise<StickerBrowseResult> {
|
||||
registerDefaultStickerProviders({});
|
||||
|
||||
const sections: StickerBrowseResult["sections"] = [];
|
||||
const recentItems = recentStickers
|
||||
.map((stickerId) => toRecentStickerItem({ stickerId }))
|
||||
.filter((item): item is StickerItem => item !== null);
|
||||
|
||||
if (recentItems.length > 0) {
|
||||
sections.push({
|
||||
id: "recent",
|
||||
title: "Recently used",
|
||||
items: recentItems.slice(0, limit),
|
||||
hasMore: recentItems.length > limit,
|
||||
layout: "row",
|
||||
});
|
||||
}
|
||||
|
||||
const settledResults = await Promise.allSettled(
|
||||
stickersRegistry.getAll().map(async (provider) => {
|
||||
const browseResult = await provider.browse({
|
||||
options: { limit },
|
||||
});
|
||||
const firstSection = browseResult.sections[0];
|
||||
|
||||
if (!firstSection || firstSection.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const category = provider.id as StickerCategory;
|
||||
return {
|
||||
...firstSection,
|
||||
id: category,
|
||||
title: STICKER_CATEGORIES[category] ?? firstSection.title,
|
||||
layout: "row" as const,
|
||||
action: {
|
||||
type: "see-all" as const,
|
||||
category,
|
||||
sectionId: firstSection.id,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
for (const result of settledResults) {
|
||||
if (result.status === "fulfilled" && result.value) {
|
||||
sections.push(result.value);
|
||||
}
|
||||
}
|
||||
|
||||
return { sections };
|
||||
}
|
||||
|
||||
export async function resolveStickerIntrinsicSize({
|
||||
stickerId,
|
||||
}: {
|
||||
stickerId: string;
|
||||
}): Promise<{ width: number; height: number }> {
|
||||
const url = resolveStickerId({ stickerId });
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () =>
|
||||
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||
img.onerror = () =>
|
||||
resolve({
|
||||
width: STICKER_INTRINSIC_SIZE_FALLBACK,
|
||||
height: STICKER_INTRINSIC_SIZE_FALLBACK,
|
||||
});
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export { resolveStickerId };
|
||||
export { resolveQueryToRegions, getRegionLabel } from "./providers/flags";
|
||||
export type {
|
||||
StickerBrowseResult,
|
||||
StickerBrowseSection,
|
||||
StickerCategory,
|
||||
StickerItem,
|
||||
StickerProvider,
|
||||
StickerResolveOptions,
|
||||
StickerSearchResult,
|
||||
} from "./types";
|
||||
import {
|
||||
STICKER_CATEGORIES,
|
||||
} from "@/lib/stickers/categories";
|
||||
import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/lib/stickers/intrinsic-size";
|
||||
import type { StickerCategory } from "@/lib/stickers/types";
|
||||
import { stickersRegistry } from "./registry";
|
||||
import { resolveStickerId } from "./resolver";
|
||||
import { registerDefaultStickerProviders } from "./providers";
|
||||
import { parseStickerId } from "./sticker-id";
|
||||
import type {
|
||||
StickerBrowseResult,
|
||||
StickerItem,
|
||||
StickerProvider,
|
||||
StickerSearchResult,
|
||||
} from "./types";
|
||||
|
||||
const DEFAULT_BROWSE_LIMIT = 12;
|
||||
const DEFAULT_SEARCH_LIMIT = 100;
|
||||
|
||||
function mergeSearchResults({
|
||||
results,
|
||||
}: {
|
||||
results: StickerSearchResult[];
|
||||
}): StickerSearchResult {
|
||||
const deduplicatedItems = new Map<
|
||||
string,
|
||||
StickerSearchResult["items"][number]
|
||||
>();
|
||||
let total = 0;
|
||||
let hasMore = false;
|
||||
|
||||
for (const result of results) {
|
||||
total += result.total;
|
||||
hasMore = hasMore || result.hasMore;
|
||||
for (const item of result.items) {
|
||||
if (!deduplicatedItems.has(item.id)) {
|
||||
deduplicatedItems.set(item.id, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items: Array.from(deduplicatedItems.values()),
|
||||
total,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
|
||||
function getProviderByCategory({
|
||||
category,
|
||||
}: {
|
||||
category: StickerCategory;
|
||||
}): StickerProvider | null {
|
||||
if (category === "all") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return stickersRegistry.get(category);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getEmptyBrowseResult(): StickerBrowseResult {
|
||||
return {
|
||||
sections: [],
|
||||
};
|
||||
}
|
||||
|
||||
function getStickerNameFromId({ stickerId }: { stickerId: string }): string {
|
||||
const stickerIdParts = stickerId.split(":");
|
||||
if (stickerIdParts.length <= 1) {
|
||||
return stickerId;
|
||||
}
|
||||
return (
|
||||
stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ??
|
||||
stickerId
|
||||
);
|
||||
}
|
||||
|
||||
function toRecentStickerItem({
|
||||
stickerId,
|
||||
}: {
|
||||
stickerId: string;
|
||||
}): StickerItem | null {
|
||||
try {
|
||||
const { providerId } = parseStickerId({ stickerId });
|
||||
return {
|
||||
id: stickerId,
|
||||
provider: providerId,
|
||||
name: getStickerNameFromId({ stickerId }),
|
||||
previewUrl: resolveStickerId({
|
||||
stickerId,
|
||||
options: { width: 64, height: 64 },
|
||||
}),
|
||||
metadata: {},
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchStickers({
|
||||
query,
|
||||
category,
|
||||
limit = DEFAULT_SEARCH_LIMIT,
|
||||
}: {
|
||||
query: string;
|
||||
category: StickerCategory;
|
||||
limit?: number;
|
||||
}): Promise<StickerSearchResult> {
|
||||
registerDefaultStickerProviders({});
|
||||
|
||||
const effectiveCategory = category in STICKER_CATEGORIES ? category : "all";
|
||||
if (effectiveCategory !== "all") {
|
||||
const provider = getProviderByCategory({ category: effectiveCategory });
|
||||
if (!provider) {
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
return provider.search({
|
||||
query,
|
||||
options: { limit },
|
||||
});
|
||||
}
|
||||
|
||||
const providers = stickersRegistry.getAll();
|
||||
if (providers.length === 0) {
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
|
||||
const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length));
|
||||
const settledResults = await Promise.allSettled(
|
||||
providers.map((provider) =>
|
||||
provider.search({
|
||||
query,
|
||||
options: { limit: perProviderLimit },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const fulfilledResults = settledResults
|
||||
.filter(
|
||||
(result): result is PromiseFulfilledResult<StickerSearchResult> =>
|
||||
result.status === "fulfilled",
|
||||
)
|
||||
.map((result) => result.value);
|
||||
|
||||
return mergeSearchResults({
|
||||
results: fulfilledResults,
|
||||
});
|
||||
}
|
||||
|
||||
export async function searchAll({
|
||||
query,
|
||||
limit = DEFAULT_SEARCH_LIMIT,
|
||||
}: {
|
||||
query: string;
|
||||
limit?: number;
|
||||
}): Promise<StickerBrowseResult> {
|
||||
registerDefaultStickerProviders({});
|
||||
|
||||
const providers = stickersRegistry.getAll();
|
||||
if (providers.length === 0) {
|
||||
return { sections: [] };
|
||||
}
|
||||
|
||||
const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length));
|
||||
const settledResults = await Promise.allSettled(
|
||||
providers.map(async (provider) => {
|
||||
const result = await provider.search({
|
||||
query,
|
||||
options: { limit: perProviderLimit },
|
||||
});
|
||||
return { provider, result };
|
||||
}),
|
||||
);
|
||||
|
||||
const sections: StickerBrowseResult["sections"] = [];
|
||||
for (const settled of settledResults) {
|
||||
if (settled.status !== "fulfilled") {
|
||||
continue;
|
||||
}
|
||||
const { provider, result } = settled.value;
|
||||
if (result.items.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const category = provider.id as StickerCategory;
|
||||
sections.push({
|
||||
id: category,
|
||||
title: STICKER_CATEGORIES[category] ?? provider.id,
|
||||
items: result.items,
|
||||
hasMore: result.hasMore,
|
||||
layout: "grid",
|
||||
});
|
||||
}
|
||||
|
||||
return { sections };
|
||||
}
|
||||
|
||||
export async function browseCategory({
|
||||
category,
|
||||
}: {
|
||||
category: StickerCategory;
|
||||
}): Promise<StickerBrowseResult> {
|
||||
registerDefaultStickerProviders({});
|
||||
|
||||
const effectiveCategory = category in STICKER_CATEGORIES ? category : "all";
|
||||
if (effectiveCategory === "all") {
|
||||
return getEmptyBrowseResult();
|
||||
}
|
||||
|
||||
const provider = getProviderByCategory({ category: effectiveCategory });
|
||||
if (!provider) {
|
||||
return getEmptyBrowseResult();
|
||||
}
|
||||
|
||||
return provider.browse({ options: {} });
|
||||
}
|
||||
|
||||
export async function browseAll({
|
||||
recentStickers,
|
||||
limit = DEFAULT_BROWSE_LIMIT,
|
||||
}: {
|
||||
recentStickers: string[];
|
||||
limit?: number;
|
||||
}): Promise<StickerBrowseResult> {
|
||||
registerDefaultStickerProviders({});
|
||||
|
||||
const sections: StickerBrowseResult["sections"] = [];
|
||||
const recentItems = recentStickers
|
||||
.map((stickerId) => toRecentStickerItem({ stickerId }))
|
||||
.filter((item): item is StickerItem => item !== null);
|
||||
|
||||
if (recentItems.length > 0) {
|
||||
sections.push({
|
||||
id: "recent",
|
||||
title: "Recently used",
|
||||
items: recentItems.slice(0, limit),
|
||||
hasMore: recentItems.length > limit,
|
||||
layout: "row",
|
||||
});
|
||||
}
|
||||
|
||||
const settledResults = await Promise.allSettled(
|
||||
stickersRegistry.getAll().map(async (provider) => {
|
||||
const browseResult = await provider.browse({
|
||||
options: { limit },
|
||||
});
|
||||
const firstSection = browseResult.sections[0];
|
||||
|
||||
if (!firstSection || firstSection.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const category = provider.id as StickerCategory;
|
||||
return {
|
||||
...firstSection,
|
||||
id: category,
|
||||
title: STICKER_CATEGORIES[category] ?? firstSection.title,
|
||||
layout: "row" as const,
|
||||
action: {
|
||||
type: "see-all" as const,
|
||||
category,
|
||||
sectionId: firstSection.id,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
for (const result of settledResults) {
|
||||
if (result.status === "fulfilled" && result.value) {
|
||||
sections.push(result.value);
|
||||
}
|
||||
}
|
||||
|
||||
return { sections };
|
||||
}
|
||||
|
||||
export async function resolveStickerIntrinsicSize({
|
||||
stickerId,
|
||||
}: {
|
||||
stickerId: string;
|
||||
}): Promise<{ width: number; height: number }> {
|
||||
const url = resolveStickerId({ stickerId });
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () =>
|
||||
resolve({ width: img.naturalWidth, height: img.naturalHeight });
|
||||
img.onerror = () =>
|
||||
resolve({
|
||||
width: STICKER_INTRINSIC_SIZE_FALLBACK,
|
||||
height: STICKER_INTRINSIC_SIZE_FALLBACK,
|
||||
});
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export { resolveStickerId };
|
||||
export { resolveQueryToRegions, getRegionLabel } from "./providers/flags";
|
||||
export type {
|
||||
StickerBrowseResult,
|
||||
StickerBrowseSection,
|
||||
StickerCategory,
|
||||
StickerItem,
|
||||
StickerProvider,
|
||||
StickerResolveOptions,
|
||||
StickerSearchResult,
|
||||
} from "./types";
|
||||
|
|
|
|||
Loading…
Reference in New Issue