chore: add project scaffolding and resources
This initial commit scaffolds the project with essential docs, assets, and build configs. It adds Android icons, design assets, and release metadata to support mobile targets. A fastlane config and F-Droid manifest are included to streamline builds. - Adds F-Droid configuration for automated builds - Includes license and privacy policy documents - Provides app icons and assets for Android and design system
This commit is contained in:
+131
-38
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, 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";
|
||||
import { deleteImages, getGallery, getImageIds, getState, nextWallpaper, selectImages, setImageCrop, setIntervalMinutes, setSetting, type GalleryImage, type WallpaperState } from "./native";
|
||||
import { initialLanguage, languageNames, languages, translations, type Language } from "./i18n-local";
|
||||
|
||||
const initial: WallpaperState = { imageCount: 0, enabled: false, shuffle: true, lockScreenOnly: true, currentIndex: 0, imageUrls: [] };
|
||||
const initial: WallpaperState = { imageCount: 0, enabled: false, intervalMinutes: 0, shuffle: true, lockScreenOnly: true, currentIndex: 0, imageUrls: [] };
|
||||
|
||||
function Switch({ checked, onChange, label }: { checked: boolean; onChange: (value: boolean) => void; label: string }) {
|
||||
return <button className={`switch ${checked ? "on" : ""}`} role="switch" aria-checked={checked} aria-label={label} onClick={() => onChange(!checked)}><span /></button>;
|
||||
@@ -13,6 +13,13 @@ function SettingRow({ icon, label, value, onChange }: { icon: React.ReactNode; l
|
||||
return <div className="setting-row"><div className="setting-icon">{icon}</div><span>{label}</span><Switch checked={value} onChange={onChange} label={label} /></div>;
|
||||
}
|
||||
|
||||
function IntervalRow({ value, onChange, label, paused, everyMinutes }: { value: number; onChange: (value: number) => void; label: string; paused: string; everyMinutes: (minutes: number) => string }) {
|
||||
return <label className="setting-row interval-row"><div className="setting-icon"><Smartphone /></div><span>{label}</span><select value={value} onChange={event => onChange(Number(event.target.value))} aria-label={label}>
|
||||
<option value={0}>{paused}</option>
|
||||
{[5, 15, 30, 60, 180, 360, 720].map(minutes => <option value={minutes} key={minutes}>{everyMinutes(minutes)}</option>)}
|
||||
</select></label>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [state, setState] = useState(initial);
|
||||
const [tab, setTab] = useState<"home" | "settings" | "gallery" | "editor">("home");
|
||||
@@ -22,11 +29,14 @@ export default function App() {
|
||||
const [gallery, setGallery] = useState<GalleryImage[]>([]);
|
||||
const [galleryTotal, setGalleryTotal] = useState(0);
|
||||
const [galleryLoading, setGalleryLoading] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
|
||||
const [editing, setEditing] = useState<GalleryImage | null>(null);
|
||||
const phonePreviewRef = useRef<HTMLDivElement>(null);
|
||||
const phoneImageRef = useRef<HTMLImageElement>(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);
|
||||
const previewGesture = useRef<null | { centerX: number; centerY: number; distance: number; x: number; y: number; zoom: number; left: number; top: number; width: number; height: number; baseWidth: number; baseHeight: number }>(null);
|
||||
const galleryScrollPosition = useRef(0);
|
||||
const restoreGalleryScroll = useRef(false);
|
||||
|
||||
useEffect(() => { getState().then(setState).catch(() => setState(initial)); }, []);
|
||||
useEffect(() => {
|
||||
@@ -39,7 +49,17 @@ export default function App() {
|
||||
document.documentElement.lang = language;
|
||||
document.title = "WallpaperFlow";
|
||||
}, [language]);
|
||||
useLayoutEffect(() => {
|
||||
if (tab !== "editor" && tab !== "gallery") return;
|
||||
const top = tab === "gallery" && restoreGalleryScroll.current ? galleryScrollPosition.current : 0;
|
||||
restoreGalleryScroll.current = false;
|
||||
window.scrollTo(0, top);
|
||||
const frame = window.requestAnimationFrame(() => window.scrollTo(0, top));
|
||||
const settled = window.setTimeout(() => window.scrollTo(0, top), 120);
|
||||
return () => { window.cancelAnimationFrame(frame); window.clearTimeout(settled); };
|
||||
}, [tab]);
|
||||
const t = translations[language];
|
||||
const previewDate = useMemo(() => new Intl.DateTimeFormat(language, { weekday: "long", day: "numeric", month: "long" }).format(new Date()), [language]);
|
||||
const current = state.imageUrls[state.currentIndex] ?? "/wallpapers/alpine.png";
|
||||
const photos = useMemo(() => state.imageUrls, [state.imageUrls]);
|
||||
|
||||
@@ -53,9 +73,23 @@ export default function App() {
|
||||
finally { setGalleryLoading(false); }
|
||||
}
|
||||
|
||||
async function update(name: "enabled" | "shuffle" | "lockScreenOnly", value: boolean) {
|
||||
async function update(name: "shuffle" | "lockScreenOnly", value: boolean) {
|
||||
setState(prev => ({ ...prev, [name]: value }));
|
||||
try { setState(await setSetting(name, value)); setNotice(name === "enabled" ? (value ? t.automaticEnabled : t.automaticDisabled) : t.settingSaved); } catch { setNotice(t.androidOnly); }
|
||||
try {
|
||||
const saved = await setSetting(name, value);
|
||||
setState(prev => ({ ...saved, imageUrls: saved.imageUrls.length ? saved.imageUrls : prev.imageUrls }));
|
||||
setNotice(t.settingSaved);
|
||||
} catch { setNotice(t.androidOnly); }
|
||||
}
|
||||
|
||||
async function updateInterval(minutes: number) {
|
||||
setState(prev => ({ ...prev, intervalMinutes: minutes, enabled: minutes > 0 }));
|
||||
try {
|
||||
const saved = await setIntervalMinutes(minutes);
|
||||
setState(prev => ({ ...saved, imageUrls: saved.imageUrls.length ? saved.imageUrls : prev.imageUrls }));
|
||||
setNotice(minutes > 0 ? t.intervalSaved : t.automaticDisabled);
|
||||
}
|
||||
catch { setNotice(t.androidOnly); }
|
||||
}
|
||||
|
||||
async function choose() {
|
||||
@@ -68,23 +102,52 @@ export default function App() {
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function remove(image: GalleryImage) {
|
||||
if (!window.confirm(t.deleteConfirm)) return;
|
||||
setDeletingId(image.id);
|
||||
function toggleSelected(id: string) {
|
||||
setSelectedIds(previous => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function selectAll() {
|
||||
if (selectedIds.size === galleryTotal) { setSelectedIds(new Set()); return; }
|
||||
setBusy(true);
|
||||
try { setSelectedIds(new Set(await getImageIds())); }
|
||||
catch { setNotice(t.galleryLoadFailed); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function removeSelected() {
|
||||
if (!selectedIds.size || !window.confirm(t.deleteSelectedConfirm)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
setState(await deleteImage(image.id));
|
||||
setGallery(previous => previous.filter(item => item.id !== image.id));
|
||||
setGalleryTotal(previous => Math.max(0, previous - 1));
|
||||
setNotice(t.imageDeleted);
|
||||
} catch { setNotice(t.imageDeleteFailed); }
|
||||
finally { setDeletingId(""); }
|
||||
setState(await deleteImages([...selectedIds]));
|
||||
setSelectedIds(new Set());
|
||||
await loadGallery();
|
||||
setNotice(t.imagesDeleted);
|
||||
} catch { setNotice(t.imagesDeleteFailed); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
function openEditor(image: GalleryImage) {
|
||||
setEditing({ ...image });
|
||||
galleryScrollPosition.current = window.scrollY;
|
||||
setEditing({ ...image, cropZoom: Math.max(1, image.cropZoom) });
|
||||
setTab("editor");
|
||||
}
|
||||
|
||||
function returnToGallery() {
|
||||
restoreGalleryScroll.current = true;
|
||||
setTab("gallery");
|
||||
}
|
||||
|
||||
function openGallery() {
|
||||
galleryScrollPosition.current = 0;
|
||||
restoreGalleryScroll.current = false;
|
||||
setTab("gallery");
|
||||
void loadGallery();
|
||||
}
|
||||
|
||||
async function saveCrop() {
|
||||
if (!editing) return;
|
||||
setBusy(true);
|
||||
@@ -93,7 +156,7 @@ export default function App() {
|
||||
setGallery(previous => previous.map(image => image.id === saved.id ? saved : image));
|
||||
setEditing(saved);
|
||||
setNotice(t.cropSaved);
|
||||
setTab("gallery");
|
||||
returnToGallery();
|
||||
} catch { setNotice(t.cropSaveFailed); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
@@ -110,13 +173,35 @@ export default function App() {
|
||||
};
|
||||
}
|
||||
|
||||
function gestureStart() {
|
||||
const preview = phonePreviewRef.current;
|
||||
const image = phoneImageRef.current;
|
||||
if (!editing || !preview || !image) return null;
|
||||
if (!image.naturalWidth || !image.naturalHeight) return null;
|
||||
const bounds = preview.getBoundingClientRect();
|
||||
const scaleX = bounds.width / image.naturalWidth;
|
||||
const scaleY = bounds.height / image.naturalHeight;
|
||||
const baseScale = editing.cropMode === "contain" ? Math.min(scaleX, scaleY) : Math.max(scaleX, scaleY);
|
||||
return {
|
||||
...gestureGeometry(),
|
||||
x: editing.cropPositionX,
|
||||
y: editing.cropPositionY,
|
||||
zoom: editing.cropZoom,
|
||||
left: bounds.left,
|
||||
top: bounds.top,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
baseWidth: image.naturalWidth * baseScale,
|
||||
baseHeight: image.naturalHeight * baseScale,
|
||||
};
|
||||
}
|
||||
|
||||
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 };
|
||||
previewGesture.current = gestureStart();
|
||||
}
|
||||
|
||||
function movePreviewGesture(event: React.PointerEvent) {
|
||||
@@ -124,15 +209,21 @@ export default function App() {
|
||||
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();
|
||||
if (!gesture) return;
|
||||
const geometry = gestureGeometry();
|
||||
const zoom = geometry.distance && gesture.distance
|
||||
? Math.min(3, Math.max(0.35, gesture.zoom * geometry.distance / gesture.distance))
|
||||
? Math.min(3, Math.max(1, 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);
|
||||
const previousLeft = (gesture.width - gesture.baseWidth * gesture.zoom) * gesture.x;
|
||||
const previousTop = (gesture.height - gesture.baseHeight * gesture.zoom) * gesture.y;
|
||||
const imageX = (gesture.centerX - gesture.left - previousLeft) / gesture.zoom;
|
||||
const imageY = (gesture.centerY - gesture.top - previousTop) / gesture.zoom;
|
||||
const nextLeft = geometry.centerX - gesture.left - imageX * zoom;
|
||||
const nextTop = geometry.centerY - gesture.top - imageY * zoom;
|
||||
const horizontalTravel = gesture.width - gesture.baseWidth * zoom;
|
||||
const verticalTravel = gesture.height - gesture.baseHeight * zoom;
|
||||
const x = Math.abs(horizontalTravel) < 0.5 ? 0.5 : nextLeft / horizontalTravel;
|
||||
const y = Math.abs(verticalTravel) < 0.5 ? 0.5 : nextTop / verticalTravel;
|
||||
setEditing(previous => previous ? {
|
||||
...previous,
|
||||
cropZoom: zoom,
|
||||
@@ -148,8 +239,7 @@ export default function App() {
|
||||
previewGesture.current = null;
|
||||
return;
|
||||
}
|
||||
const geometry = gestureGeometry();
|
||||
previewGesture.current = { ...geometry, x: editing.cropPositionX, y: editing.cropPositionY, zoom: editing.cropZoom };
|
||||
previewGesture.current = gestureStart();
|
||||
}
|
||||
|
||||
async function next() {
|
||||
@@ -159,8 +249,8 @@ export default function App() {
|
||||
}
|
||||
|
||||
return <main className="app-shell">
|
||||
{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> :
|
||||
{tab === "gallery" ? <header className="gallery-header"><button className="icon-button" aria-label={t.back} onClick={() => { setSelectedIds(new Set()); setTab("home"); }}><ArrowLeft /></button><div><h1>{selectedIds.size ? `${selectedIds.size} ${t.selected}` : t.collection}</h1><p>{galleryTotal} {galleryTotal === 1 ? t.image : t.images}</p></div>{selectedIds.size ? <button className="icon-button delete-selection" aria-label={t.deleteSelected} onClick={removeSelected} disabled={busy}><Trash2 /></button> : <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={returnToGallery}><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">
|
||||
@@ -172,30 +262,33 @@ export default function App() {
|
||||
<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={t.changeOnWake} value={state.enabled} onChange={v => update("enabled", v)} />
|
||||
<IntervalRow value={state.intervalMinutes} onChange={updateInterval} label={t.changeInterval} paused={t.paused} everyMinutes={t.everyMinutes} />
|
||||
|
||||
<button className="primary" onClick={choose} disabled={busy}><Images />{busy ? t.pleaseWait : t.selectImages}</button>
|
||||
|
||||
<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>
|
||||
<section className="collection"><div className="section-heading"><h2>{t.collection}</h2><button onClick={openGallery}>{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={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" ?
|
||||
</> : tab === "settings" ? <section className="settings-page"><h2>{t.settings}</h2><p>{t.settingsIntro}</p><label className="language-setting"><div className="setting-icon"><Languages /></div><div><strong>{t.language}</strong><span>{languageNames[language]}</span></div><select className="locale-select" value={language} onChange={event => setLanguage(event.target.value as Language)} aria-label={t.language}>{languages.map(locale => <option value={locale} key={locale}>{languageNames[locale]}</option>)}</select></label><div className="settings-list"><IntervalRow value={state.intervalMinutes} onChange={updateInterval} label={t.changeInterval} paused={t.paused} everyMinutes={t.everyMinutes} /><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>}
|
||||
{!!galleryTotal && <div className="selection-toolbar"><span>{selectedIds.size ? `${selectedIds.size} ${t.selected}` : t.selectImagesToDelete}</span><button onClick={selectAll} disabled={busy}>{selectedIds.size === galleryTotal ? t.clearSelection : t.selectAll}</button></div>}
|
||||
{gallery.length ? <div className="gallery-grid">{gallery.map((image, index) => <article className={`${image.selected ? "current " : ""}${selectedIds.has(image.id) ? "chosen" : ""}`} key={image.id}><button className="gallery-image-button" aria-label={`${selectedIds.has(image.id) ? t.deselectImage : t.selectImage} ${index + 1}`} aria-pressed={selectedIds.has(image.id)} onClick={() => toggleSelected(image.id)}><img src={image.url} alt={`${t.image} ${index + 1}`} /></button>{image.selected && <span className="current-badge"><Check /> {t.current}</span>}<span className="selection-check"><Check /></span><button className="edit-button" aria-label={`${t.adjustImage} ${index + 1}`} onClick={() => openEditor(image)}><Crop /></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>
|
||||
<img ref={phoneImageRef} 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="crop-grid" aria-hidden="true"><i /><i /><i /><i /></div>
|
||||
<div className="preview-clock">12:34<span>{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="fit-toggle" aria-label={t.imageFit}><button className={editing.cropMode === "cover" ? "active" : ""} onClick={() => setEditing({ ...editing, cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5 })}>{t.fill}</button><button className={editing.cropMode === "contain" ? "active" : ""} onClick={() => setEditing({ ...editing, cropMode: "contain", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5 })}>{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>
|
||||
<label className="zoom-control"><span>{t.zoom}</span><input type="range" min="1" max="3" step="0.01" value={editing.cropZoom} onChange={event => setEditing({ ...editing, cropZoom: Number(event.target.value) })} /></label>
|
||||
<button className="reset-crop" onClick={() => setEditing({ ...editing, cropMode: "cover", cropZoom: 1, cropPositionX: 0.5, cropPositionY: 0.5 })}><RotateCcw /> {t.reset}</button>
|
||||
</div>
|
||||
</section>}
|
||||
|
||||
Reference in New Issue
Block a user