mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add frontend store and components for html-to-image tool
This commit is contained in:
@@ -0,0 +1,61 @@
|
|||||||
|
import { Download, Loader2 } from "lucide-react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
|
import { useHtmlToImageStore } from "@/stores/html-to-image-store";
|
||||||
|
|
||||||
|
export function HtmlToImageResults() {
|
||||||
|
const store = useHtmlToImageStore();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const ts = t.toolSettings["html-to-image"];
|
||||||
|
|
||||||
|
if (store.capturing) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
<p className="text-sm">{ts.capturing}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!store.resultUrl) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||||
|
<p className="text-sm">{ts.placeholder}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = store.resultUrl!;
|
||||||
|
a.download = `screenshot.${store.format}`;
|
||||||
|
a.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="flex-1 overflow-auto p-4">
|
||||||
|
<img
|
||||||
|
src={store.resultUrl}
|
||||||
|
alt="Captured screenshot"
|
||||||
|
className="mx-auto max-w-full rounded-lg border border-border shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-t border-border p-4">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{store.resultSize != null
|
||||||
|
? store.resultSize >= 1024 * 1024
|
||||||
|
? `${(store.resultSize / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
: `${(store.resultSize / 1024).toFixed(1)} KB`
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
{ts.download}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
|
import { useHtmlToImageStore } from "@/stores/html-to-image-store";
|
||||||
|
|
||||||
|
export function HtmlToImageSettings() {
|
||||||
|
const store = useHtmlToImageStore();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const ts = t.toolSettings["html-to-image"];
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
store.capture();
|
||||||
|
},
|
||||||
|
[store],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium">{ts.url}</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={store.url}
|
||||||
|
onChange={(e) => store.setUrl(e.target.value)}
|
||||||
|
placeholder={ts.urlPlaceholder}
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium">{ts.format}</label>
|
||||||
|
<select
|
||||||
|
value={store.format}
|
||||||
|
onChange={(e) => store.setFormat(e.target.value as "jpg" | "png" | "webp")}
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="png">PNG</option>
|
||||||
|
<option value="jpg">JPG</option>
|
||||||
|
<option value="webp">WebP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{store.format !== "png" && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium">
|
||||||
|
{ts.quality}: {store.quality}%
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={store.quality}
|
||||||
|
onChange={(e) => store.setQuality(Number(e.target.value))}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium">{ts.devicePreset}</label>
|
||||||
|
<select
|
||||||
|
value={store.devicePreset}
|
||||||
|
onChange={(e) =>
|
||||||
|
store.setDevicePreset(e.target.value as "desktop" | "tablet" | "mobile" | "custom")
|
||||||
|
}
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option value="desktop">{ts.presets.desktop}</option>
|
||||||
|
<option value="tablet">{ts.presets.tablet}</option>
|
||||||
|
<option value="mobile">{ts.presets.mobile}</option>
|
||||||
|
<option value="custom">{ts.presets.custom}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{store.devicePreset === "custom" && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium">{ts.viewportWidth}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={320}
|
||||||
|
max={3840}
|
||||||
|
value={store.viewportWidth}
|
||||||
|
onChange={(e) => store.setViewportWidth(Number(e.target.value))}
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium">{ts.viewportHeight}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={320}
|
||||||
|
max={2160}
|
||||||
|
value={store.viewportHeight}
|
||||||
|
onChange={(e) => store.setViewportHeight(Number(e.target.value))}
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-sm font-medium">{ts.fullPage}</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={store.fullPage}
|
||||||
|
onClick={() => store.setFullPage(!store.fullPage)}
|
||||||
|
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
|
||||||
|
store.fullPage ? "bg-primary" : "bg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform ${
|
||||||
|
store.fullPage ? "translate-x-5" : "translate-x-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{store.error && (
|
||||||
|
<div className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{store.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!store.url || store.capturing}
|
||||||
|
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{store.capturing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{store.capturing ? ts.capturing : ts.submit}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
|
|
||||||
|
interface HtmlToImageState {
|
||||||
|
url: string;
|
||||||
|
format: "jpg" | "png" | "webp";
|
||||||
|
quality: number;
|
||||||
|
fullPage: boolean;
|
||||||
|
devicePreset: "desktop" | "tablet" | "mobile" | "custom";
|
||||||
|
viewportWidth: number;
|
||||||
|
viewportHeight: number;
|
||||||
|
capturing: boolean;
|
||||||
|
resultUrl: string | null;
|
||||||
|
resultSize: number | null;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
setUrl: (url: string) => void;
|
||||||
|
setFormat: (format: "jpg" | "png" | "webp") => void;
|
||||||
|
setQuality: (quality: number) => void;
|
||||||
|
setFullPage: (fullPage: boolean) => void;
|
||||||
|
setDevicePreset: (preset: "desktop" | "tablet" | "mobile" | "custom") => void;
|
||||||
|
setViewportWidth: (width: number) => void;
|
||||||
|
setViewportHeight: (height: number) => void;
|
||||||
|
capture: () => Promise<void>;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
url: "",
|
||||||
|
format: "png" as const,
|
||||||
|
quality: 90,
|
||||||
|
fullPage: false,
|
||||||
|
devicePreset: "desktop" as const,
|
||||||
|
viewportWidth: 1280,
|
||||||
|
viewportHeight: 720,
|
||||||
|
capturing: false,
|
||||||
|
resultUrl: null as string | null,
|
||||||
|
resultSize: null as number | null,
|
||||||
|
error: null as string | null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useHtmlToImageStore = create<HtmlToImageState>((set, get) => ({
|
||||||
|
...DEFAULTS,
|
||||||
|
|
||||||
|
setUrl: (url) => set({ url, error: null }),
|
||||||
|
setFormat: (format) => set({ format }),
|
||||||
|
setQuality: (quality) => set({ quality }),
|
||||||
|
setFullPage: (fullPage) => set({ fullPage }),
|
||||||
|
setDevicePreset: (devicePreset) => set({ devicePreset }),
|
||||||
|
setViewportWidth: (viewportWidth) => set({ viewportWidth }),
|
||||||
|
setViewportHeight: (viewportHeight) => set({ viewportHeight }),
|
||||||
|
|
||||||
|
capture: async () => {
|
||||||
|
const state = get();
|
||||||
|
if (!state.url || state.capturing) return;
|
||||||
|
|
||||||
|
set({ capturing: true, error: null, resultUrl: null, resultSize: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/tools/html-to-image", {
|
||||||
|
method: "POST",
|
||||||
|
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: state.url,
|
||||||
|
format: state.format,
|
||||||
|
quality: state.quality,
|
||||||
|
fullPage: state.fullPage,
|
||||||
|
devicePreset: state.devicePreset,
|
||||||
|
viewportWidth: state.viewportWidth,
|
||||||
|
viewportHeight: state.viewportHeight,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
set({
|
||||||
|
error: data.details || data.error || "Capture failed",
|
||||||
|
capturing: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({
|
||||||
|
resultUrl: data.downloadUrl,
|
||||||
|
resultSize: data.processedSize,
|
||||||
|
capturing: false,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
set({
|
||||||
|
error: err instanceof Error ? err.message : "Network error",
|
||||||
|
capturing: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
reset: () => set({ ...DEFAULTS }),
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user