feat(wallpapers): add image crop support and i18n strings
Adds per-image image-crop support with persistent settings. Rendering adapts to crop mode, zoom and position per image. Locales the UI strings and notifications; app name updated. - Cropping workflow with per-image crop state and rendering - Localizes strings and notifications for en and de - Introduces set_image_crop command and its permission schema
This commit is contained in:
+130
-29
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, Check, ChevronRight, Home, Images, LockKeyhole, Plus, Settings, Shuffle, Smartphone, Sparkles, Trash2 } from "lucide-react";
|
||||
import { deleteImage, getGallery, getState, nextWallpaper, selectImages, setSetting, type GalleryImage, type WallpaperState } from "./native";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ArrowLeft, Check, ChevronRight, Crop, Home, Images, Languages, LockKeyhole, Plus, RotateCcw, Save, Settings, Shuffle, Smartphone, Sparkles, Trash2 } from "lucide-react";
|
||||
import { deleteImage, getGallery, getState, nextWallpaper, selectImages, setImageCrop, setSetting, type GalleryImage, type WallpaperState } from "./native";
|
||||
import { initialLanguage, translations, type Language } from "./i18n";
|
||||
|
||||
const initial: WallpaperState = { imageCount: 0, enabled: false, shuffle: true, lockScreenOnly: true, currentIndex: 0, imageUrls: [] };
|
||||
|
||||
@@ -14,15 +15,31 @@ function SettingRow({ icon, label, value, onChange }: { icon: React.ReactNode; l
|
||||
|
||||
export default function App() {
|
||||
const [state, setState] = useState(initial);
|
||||
const [tab, setTab] = useState<"home" | "settings" | "gallery">("home");
|
||||
const [tab, setTab] = useState<"home" | "settings" | "gallery" | "editor">("home");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [language, setLanguage] = useState<Language>(initialLanguage);
|
||||
const [gallery, setGallery] = useState<GalleryImage[]>([]);
|
||||
const [galleryTotal, setGalleryTotal] = useState(0);
|
||||
const [galleryLoading, setGalleryLoading] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState("");
|
||||
const [editing, setEditing] = useState<GalleryImage | null>(null);
|
||||
const phonePreviewRef = useRef<HTMLDivElement>(null);
|
||||
const previewPointers = useRef(new Map<number, { x: number; y: number }>());
|
||||
const previewGesture = useRef<null | { centerX: number; centerY: number; distance: number; x: number; y: number; zoom: number }>(null);
|
||||
|
||||
useEffect(() => { getState().then(setState).catch(() => setState(initial)); }, []);
|
||||
useEffect(() => {
|
||||
if (!notice) return;
|
||||
const timeout = window.setTimeout(() => setNotice(""), 3000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [notice]);
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem("wallpaperflow-language", language);
|
||||
document.documentElement.lang = language;
|
||||
document.title = "WallpaperFlow";
|
||||
}, [language]);
|
||||
const t = translations[language];
|
||||
const current = state.imageUrls[state.currentIndex] ?? "/wallpapers/alpine.png";
|
||||
const photos = useMemo(() => state.imageUrls, [state.imageUrls]);
|
||||
|
||||
@@ -32,13 +49,13 @@ export default function App() {
|
||||
const page = await getGallery(offset, 48);
|
||||
setGallery(previous => append ? [...previous, ...page.items] : page.items);
|
||||
setGalleryTotal(page.total);
|
||||
} catch { setNotice("Galerie konnte nicht geladen werden"); }
|
||||
} catch { setNotice(t.galleryLoadFailed); }
|
||||
finally { setGalleryLoading(false); }
|
||||
}
|
||||
|
||||
async function update(name: "enabled" | "shuffle" | "lockScreenOnly", value: boolean) {
|
||||
setState(prev => ({ ...prev, [name]: value }));
|
||||
try { setState(await setSetting(name, value)); setNotice(name === "enabled" ? (value ? "Automatischer Wechsel ist aktiv" : "Automatischer Wechsel pausiert") : "Einstellung gespeichert"); } catch { setNotice("Diese Funktion ist auf Android verfügbar"); }
|
||||
try { setState(await setSetting(name, value)); setNotice(name === "enabled" ? (value ? t.automaticEnabled : t.automaticDisabled) : t.settingSaved); } catch { setNotice(t.androidOnly); }
|
||||
}
|
||||
|
||||
async function choose() {
|
||||
@@ -46,61 +63,145 @@ export default function App() {
|
||||
try {
|
||||
setState(await selectImages());
|
||||
if (tab === "gallery") await loadGallery();
|
||||
setNotice("Bilder wurden zur Sammlung hinzugefügt");
|
||||
} catch (error) { setNotice(String(error).includes("cancel") ? "Auswahl abgebrochen" : "Bildauswahl ist auf Android verfügbar"); }
|
||||
setNotice(t.imagesAdded);
|
||||
} catch (error) { setNotice(String(error).includes("cancel") ? t.selectionCancelled : t.imageSelectionAndroid); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function remove(image: GalleryImage) {
|
||||
if (!window.confirm("Möchtest du dieses Bild wirklich aus deiner Sammlung löschen?")) return;
|
||||
if (!window.confirm(t.deleteConfirm)) return;
|
||||
setDeletingId(image.id);
|
||||
try {
|
||||
setState(await deleteImage(image.id));
|
||||
setGallery(previous => previous.filter(item => item.id !== image.id));
|
||||
setGalleryTotal(previous => Math.max(0, previous - 1));
|
||||
setNotice("Bild wurde gelöscht");
|
||||
} catch { setNotice("Bild konnte nicht gelöscht werden"); }
|
||||
setNotice(t.imageDeleted);
|
||||
} catch { setNotice(t.imageDeleteFailed); }
|
||||
finally { setDeletingId(""); }
|
||||
}
|
||||
|
||||
function openEditor(image: GalleryImage) {
|
||||
setEditing({ ...image });
|
||||
setTab("editor");
|
||||
}
|
||||
|
||||
async function saveCrop() {
|
||||
if (!editing) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const saved = await setImageCrop(editing);
|
||||
setGallery(previous => previous.map(image => image.id === saved.id ? saved : image));
|
||||
setEditing(saved);
|
||||
setNotice(t.cropSaved);
|
||||
setTab("gallery");
|
||||
} catch { setNotice(t.cropSaveFailed); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
function gestureGeometry() {
|
||||
const points = [...previewPointers.current.values()];
|
||||
if (!points.length) return { centerX: 0, centerY: 0, distance: 0 };
|
||||
if (points.length === 1) return { centerX: points[0].x, centerY: points[0].y, distance: 0 };
|
||||
const [first, second] = points;
|
||||
return {
|
||||
centerX: (first.x + second.x) / 2,
|
||||
centerY: (first.y + second.y) / 2,
|
||||
distance: Math.hypot(second.x - first.x, second.y - first.y),
|
||||
};
|
||||
}
|
||||
|
||||
function beginPreviewGesture(event: React.PointerEvent) {
|
||||
if (!editing) return;
|
||||
event.preventDefault();
|
||||
phonePreviewRef.current?.setPointerCapture(event.pointerId);
|
||||
previewPointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
const geometry = gestureGeometry();
|
||||
previewGesture.current = { ...geometry, x: editing.cropPositionX, y: editing.cropPositionY, zoom: editing.cropZoom };
|
||||
}
|
||||
|
||||
function movePreviewGesture(event: React.PointerEvent) {
|
||||
if (!previewPointers.current.has(event.pointerId)) return;
|
||||
event.preventDefault();
|
||||
previewPointers.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
const gesture = previewGesture.current;
|
||||
const preview = phonePreviewRef.current;
|
||||
if (!gesture || !preview) return;
|
||||
const bounds = preview.getBoundingClientRect();
|
||||
const geometry = gestureGeometry();
|
||||
const zoom = geometry.distance && gesture.distance
|
||||
? Math.min(3, Math.max(0.35, gesture.zoom * geometry.distance / gesture.distance))
|
||||
: gesture.zoom;
|
||||
const x = gesture.x - (geometry.centerX - gesture.centerX) / Math.max(1, bounds.width * zoom);
|
||||
const y = gesture.y - (geometry.centerY - gesture.centerY) / Math.max(1, bounds.height * zoom);
|
||||
setEditing(previous => previous ? {
|
||||
...previous,
|
||||
cropZoom: zoom,
|
||||
cropPositionX: Math.min(1, Math.max(0, x)),
|
||||
cropPositionY: Math.min(1, Math.max(0, y)),
|
||||
} : previous);
|
||||
}
|
||||
|
||||
function endPreviewGesture(event: React.PointerEvent) {
|
||||
previewPointers.current.delete(event.pointerId);
|
||||
if (phonePreviewRef.current?.hasPointerCapture(event.pointerId)) phonePreviewRef.current.releasePointerCapture(event.pointerId);
|
||||
if (!editing || !previewPointers.current.size) {
|
||||
previewGesture.current = null;
|
||||
return;
|
||||
}
|
||||
const geometry = gestureGeometry();
|
||||
previewGesture.current = { ...geometry, x: editing.cropPositionX, y: editing.cropPositionY, zoom: editing.cropZoom };
|
||||
}
|
||||
|
||||
async function next() {
|
||||
setBusy(true);
|
||||
try { setState(await nextWallpaper()); setNotice("Sperrbildschirm wurde aktualisiert"); } catch { setState(prev => ({ ...prev, currentIndex: (prev.currentIndex + 1) % Math.max(1, photos.length) })); }
|
||||
try { setState(await nextWallpaper()); setNotice(t.wallpaperUpdated); } catch { setState(prev => ({ ...prev, currentIndex: (prev.currentIndex + 1) % Math.max(1, photos.length) })); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return <main className="app-shell">
|
||||
{tab === "gallery" ? <header className="gallery-header"><button className="icon-button" aria-label="Zurück" onClick={() => setTab("home")}><ArrowLeft /></button><div><h1>Meine Sammlung</h1><p>{galleryTotal} {galleryTotal === 1 ? "Bild" : "Bilder"}</p></div><button className="icon-button" aria-label="Bilder hinzufügen" onClick={choose} disabled={busy}><Plus /></button></header> :
|
||||
<header><div><h1>LockScreenWallpaper</h1><p>{state.enabled ? "Deine Motive wechseln automatisch" : "Automatischer Wechsel ist pausiert"}</p></div><button className="icon-button" aria-label="Einstellungen" onClick={() => setTab("settings")}><Settings /></button></header>}
|
||||
{tab === "gallery" ? <header className="gallery-header"><button className="icon-button" aria-label={t.back} onClick={() => setTab("home")}><ArrowLeft /></button><div><h1>{t.collection}</h1><p>{galleryTotal} {galleryTotal === 1 ? t.image : t.images}</p></div><button className="icon-button" aria-label={t.addImages} onClick={choose} disabled={busy}><Plus /></button></header> : tab === "editor" ?
|
||||
<header className="gallery-header"><button className="icon-button" aria-label={t.backToGallery} onClick={() => setTab("gallery")}><ArrowLeft /></button><div><h1>{t.editImage}</h1><p>{t.savedForImage}</p></div><button className="icon-button save-crop" aria-label={t.saveCrop} onClick={saveCrop} disabled={busy}><Save /></button></header> :
|
||||
<header className="app-header"><div className="brand"><img className="brand-logo" src="/app-icon.svg" alt="" /><div className="brand-copy"><h1>WallpaperFlow</h1><p>{state.enabled ? t.automaticActive : t.automaticPaused}</p></div></div><button className={`icon-button settings-shortcut ${tab === "settings" ? "selected" : ""}`} aria-label={t.settings} aria-current={tab === "settings" ? "page" : undefined} onClick={() => setTab("settings")}><Settings /></button></header>}
|
||||
|
||||
<div className="content">
|
||||
{tab === "home" ? <>
|
||||
<section className="hero" aria-label="Aktuelles Hintergrundbild">
|
||||
<section className="hero" aria-label={t.currentWallpaper}>
|
||||
<div className="photo-stack" />
|
||||
<img src={current} alt="Aktuelles Motiv" />
|
||||
<img src={current} alt={t.currentImage} />
|
||||
<div className="hero-shade" />
|
||||
<div className="hero-meta"><span><Sparkles size={16} /> Aktuelles Motiv</span><button onClick={next} disabled={busy}>Nächstes Motiv <ChevronRight size={18} /></button></div>
|
||||
<div className="hero-meta"><span><Sparkles size={16} /> {t.currentImage}</span><button onClick={next} disabled={busy}>{t.nextImage} <ChevronRight size={18} /></button></div>
|
||||
</section>
|
||||
|
||||
<SettingRow icon={<Smartphone />} label="Bei jedem Aktivieren wechseln" value={state.enabled} onChange={v => update("enabled", v)} />
|
||||
<SettingRow icon={<Smartphone />} label={t.changeOnWake} value={state.enabled} onChange={v => update("enabled", v)} />
|
||||
|
||||
<button className="primary" onClick={choose} disabled={busy}><Images />{busy ? "Bitte warten …" : "Bilder auswählen"}</button>
|
||||
<button className="primary" onClick={choose} disabled={busy}><Images />{busy ? t.pleaseWait : t.selectImages}</button>
|
||||
|
||||
<section className="collection"><div className="section-heading"><h2>Meine Sammlung</h2><button onClick={() => { setTab("gallery"); void loadGallery(); }}>{state.imageCount} {state.imageCount === 1 ? "Bild" : "Bilder"} <ChevronRight /></button></div>
|
||||
{photos.length ? <div className="photo-rail">{photos.map((photo, index) => <button key={`${photo}-${index}`} className={index === state.currentIndex ? "selected" : ""} onClick={() => setState(prev => ({ ...prev, currentIndex: index }))}><img src={photo} alt={`Motiv ${index + 1}`} />{index === state.currentIndex && <span><Check /></span>}</button>)}</div> : <button className="empty-collection" onClick={choose}><Images /><span>Noch keine Bilder ausgewählt</span></button>}
|
||||
<p className="hint">Tippe auf die Bildanzahl, um deine Galerie zu öffnen.</p>
|
||||
<section className="collection"><div className="section-heading"><h2>{t.collection}</h2><button onClick={() => { setTab("gallery"); void loadGallery(); }}>{state.imageCount} {state.imageCount === 1 ? t.image : t.images} <ChevronRight /></button></div>
|
||||
{photos.length ? <div className="photo-rail">{photos.map((photo, index) => <button key={`${photo}-${index}`} className={index === state.currentIndex ? "selected" : ""} onClick={() => setState(prev => ({ ...prev, currentIndex: index }))}><img src={photo} alt={`${t.motif} ${index + 1}`} />{index === state.currentIndex && <span><Check /></span>}</button>)}</div> : <button className="empty-collection" onClick={choose}><Images /><span>{t.noImages}</span></button>}
|
||||
<p className="hint">{t.collectionHint}</p>
|
||||
</section>
|
||||
|
||||
<section className="settings-list"><SettingRow icon={<Shuffle />} label="Zufällige Reihenfolge" value={state.shuffle} onChange={v => update("shuffle", v)} /><SettingRow icon={<LockKeyhole />} label="Nur Sperrbildschirm" value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} /></section>
|
||||
</> : tab === "settings" ? <section className="settings-page"><h2>Einstellungen</h2><p>Lege fest, wie LockScreenWallpaper im Hintergrund arbeitet.</p><div className="settings-list"><SettingRow icon={<Smartphone />} label="Bei jedem Aktivieren wechseln" value={state.enabled} onChange={v => update("enabled", v)} /><SettingRow icon={<Shuffle />} label="Zufällige Reihenfolge" value={state.shuffle} onChange={v => update("shuffle", v)} /><SettingRow icon={<LockKeyhole />} label="Nur Sperrbildschirm" value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} /></div><div className="info"><h3>Ohne Bilderlimit</h3><p>Deine Auswahl wird privat auf dem Gerät gespeichert. Die einzige Grenze ist der freie Speicherplatz.</p></div></section> :
|
||||
<section className="gallery-page" aria-label="Meine Bilder">
|
||||
{gallery.length ? <div className="gallery-grid">{gallery.map((image, index) => <article className={image.selected ? "current" : ""} key={image.id}><img src={image.url} alt={`Bild ${index + 1}`} />{image.selected && <span className="current-badge"><Check /> Aktuell</span>}<button className="delete-button" aria-label={`Bild ${index + 1} löschen`} onClick={() => remove(image)} disabled={deletingId === image.id}><Trash2 /></button></article>)}</div> : !galleryLoading && <div className="gallery-empty"><Images /><h2>Deine Sammlung ist leer</h2><p>Füge Bilder hinzu, die automatisch als Sperrbildschirm wechseln sollen.</p><button className="primary" onClick={choose}><Plus /> Bilder hinzufügen</button></div>}
|
||||
{galleryLoading && <p className="gallery-status">Galerie wird geladen …</p>}
|
||||
{!galleryLoading && gallery.length < galleryTotal && <button className="load-more" onClick={() => loadGallery(gallery.length, true)}>Weitere Bilder laden</button>}
|
||||
<section className="settings-list"><SettingRow icon={<Shuffle />} label={t.shuffle} value={state.shuffle} onChange={v => update("shuffle", v)} /><SettingRow icon={<LockKeyhole />} label={t.lockScreenOnly} value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} /></section>
|
||||
</> : tab === "settings" ? <section className="settings-page"><h2>{t.settings}</h2><p>{t.settingsIntro}</p><div className="language-setting"><div className="setting-icon"><Languages /></div><div><strong>{t.language}</strong><span>{language === "de" ? t.german : t.english}</span></div><div className="language-toggle" role="group" aria-label={t.language}><button className={language === "de" ? "active" : ""} onClick={() => setLanguage("de")}>DE</button><button className={language === "en" ? "active" : ""} onClick={() => setLanguage("en")}>EN</button></div></div><div className="settings-list"><SettingRow icon={<Smartphone />} label={t.changeOnWake} value={state.enabled} onChange={v => update("enabled", v)} /><SettingRow icon={<Shuffle />} label={t.shuffle} value={state.shuffle} onChange={v => update("shuffle", v)} /><SettingRow icon={<LockKeyhole />} label={t.lockScreenOnly} value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} /></div><div className="info"><h3>{t.noImageLimit}</h3><p>{t.privacyInfo}</p></div></section> : tab === "gallery" ?
|
||||
<section className="gallery-page" aria-label={t.myImages}>
|
||||
{gallery.length ? <div className="gallery-grid">{gallery.map((image, index) => <article className={image.selected ? "current" : ""} key={image.id}><img src={image.url} alt={`${t.image} ${index + 1}`} />{image.selected && <span className="current-badge"><Check /> {t.current}</span>}<button className="edit-button" aria-label={`${t.adjustImage} ${index + 1}`} onClick={() => openEditor(image)}><Crop /></button><button className="delete-button" aria-label={`${t.deleteImage} ${index + 1}`} onClick={() => remove(image)} disabled={deletingId === image.id}><Trash2 /></button></article>)}</div> : !galleryLoading && <div className="gallery-empty"><Images /><h2>{t.emptyCollection}</h2><p>{t.emptyCollectionText}</p><button className="primary" onClick={choose}><Plus /> {t.addImages}</button></div>}
|
||||
{galleryLoading && <p className="gallery-status">{t.galleryLoading}</p>}
|
||||
{!galleryLoading && gallery.length < galleryTotal && <button className="load-more" onClick={() => loadGallery(gallery.length, true)}>{t.loadMore}</button>}
|
||||
</section> : editing && <section className="crop-editor" aria-label={t.editCrop}>
|
||||
<div ref={phonePreviewRef} className="phone-preview interactive" aria-label={t.gestureLabel} onPointerDown={beginPreviewGesture} onPointerMove={movePreviewGesture} onPointerUp={endPreviewGesture} onPointerCancel={endPreviewGesture}>
|
||||
<img src={editing.url} alt={t.cropPreview} draggable={false} style={{ objectFit: editing.cropMode, objectPosition: `${editing.cropPositionX * 100}% ${editing.cropPositionY * 100}%`, transform: `scale(${editing.cropZoom})`, transformOrigin: `${editing.cropPositionX * 100}% ${editing.cropPositionY * 100}%` }} />
|
||||
<div className="preview-clock">12:34<span>{t.previewDate}</span></div>
|
||||
</div>
|
||||
<div className="crop-controls">
|
||||
<div className="fit-toggle" aria-label={t.imageFit}><button className={editing.cropMode === "cover" ? "active" : ""} onClick={() => setEditing({ ...editing, cropMode: "cover" })}>{t.fill}</button><button className={editing.cropMode === "contain" ? "active" : ""} onClick={() => setEditing({ ...editing, cropMode: "contain" })}>{t.fit}</button></div>
|
||||
<div className="direct-crop-heading"><span><Crop /> {t.adjustOnScreen}</span><strong>{editing.cropZoom.toFixed(2)}×</strong></div>
|
||||
<p className="direct-crop-help">{t.gestureHelp}</p>
|
||||
<button className="reset-crop" onClick={() => setEditing({ ...editing, cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5 })}><RotateCcw /> {t.reset}</button>
|
||||
</div>
|
||||
</section>}
|
||||
</div>
|
||||
|
||||
{notice && <button className="snackbar" onClick={() => setNotice("")}>{notice}</button>}
|
||||
{tab !== "gallery" && <nav><button className={tab === "home" ? "active" : ""} onClick={() => setTab("home")}><Home /><span>Start</span></button><button className={tab === "settings" ? "active" : ""} onClick={() => setTab("settings")}><Settings /><span>Einstellungen</span></button></nav>}
|
||||
{tab !== "gallery" && tab !== "editor" && <nav><button className={tab === "home" ? "active" : ""} onClick={() => setTab("home")}><Home /><span>{t.home}</span></button><button className={tab === "settings" ? "active" : ""} onClick={() => setTab("settings")}><Settings /><span>{t.settings}</span></button></nav>}
|
||||
</main>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user