diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx
index 7c79683..5671ce7 100644
--- a/docker/frontend/src/App.tsx
+++ b/docker/frontend/src/App.tsx
@@ -17,6 +17,7 @@ import { loadEngine, MARK_FACES } from './engine/assets';
import { renderPhoto, type RenderAssets } from './engine/exportEngine';
import { download, fileToBytes, readCapturedAt, readGps, readIso, readSpecs, resizedJpeg, scaledJpeg, type ShotSpecs } from './engine/imageOps';
import { developRaw, isRawName } from './engine/rawDevelop';
+import { sharePhoto, type ShareSite } from './engine/share';
import { heicToJpeg, isHeicName } from './engine/heicDevelop';
import { clearRaw, readRaw, storeRaw } from './engine/rawStore';
import { getPhoto, loadEdit, readPhotoFile, saveEdit, setRotation as savePhotoRotation } from './engine/library';
@@ -1748,7 +1749,7 @@ export function Workspace() {
// `true` when a file was handed over, so CLEAR knows the export it offered
// actually happened before the photo comes off the stage.
- const exportPhoto = useCallback(async (target: number | null): Promise => {
+ const exportPhoto = useCallback(async (target: number | null, share?: ShareSite): Promise => {
if (!engine || !source) return false;
if (!pro && proLookInUse()) {
promptPro();
@@ -1833,7 +1834,11 @@ export function Workspace() {
source.bytes
);
const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
- download(bytes, `recipescam_${(recipe.name || 'photo').replace(/\W+/g, '-').toLowerCase()}_${stamp}.jpg`);
+ const name = `recipescam_${(recipe.name || 'photo').replace(/\W+/g, '-').toLowerCase()}_${stamp}.jpg`;
+ // SHARE hands the same bytes to the browser's share sheet instead of the
+ // download folder, so the file never lands on the machine on the way out.
+ if (share) await sharePhoto(bytes, name, share);
+ else download(bytes, name);
// The moment the app has done the visitor's work is the moment it has earned
// a place on the machine: the install offer waits for this instead of
// opening on the way in (which nobody reads).
@@ -4141,6 +4146,10 @@ export function Workspace() {
setChoosingExport(false);
void exportPhoto(target);
}}
+ onShare={(site) => {
+ setChoosingExport(false);
+ void exportPhoto(null, site);
+ }}
onClose={() => setChoosingExport(false)}
/>
) : null}
diff --git a/docker/frontend/src/engine/share.ts b/docker/frontend/src/engine/share.ts
new file mode 100644
index 0000000..a1f08d2
--- /dev/null
+++ b/docker/frontend/src/engine/share.ts
@@ -0,0 +1,72 @@
+import { download } from './imageOps';
+
+// Posting the export to a social site. A page cannot reach into another
+// origin's composer: the cookie is the browser's to send, never ours to read,
+// and the window we open has no DOM we may touch — which is the same rule that
+// keeps any other site from posting as the visitor. So there is no way to drop
+// the picture into the site's own picker from here. Two things do work, and
+// this takes the best one the browser offers:
+//
+// sheet on a phone the OS share sheet opens and the target app —
+// Facebook, Instagram, Reddit — comes up with the picture already
+// in its composer. Only the caption is left to type.
+// clipboard the desktop path: the picture goes on the clipboard and the site
+// opens beside it, so one paste (Ctrl+V) puts it in the composer.
+// download no share sheet and no clipboard — an http origin is not a secure
+// context, so the API is not even there: the file is handed over the
+// old way and the site opens beside it.
+//
+// ponytail: the four sites are listed here rather than in a config file. Add a
+// network when someone asks for one; these three paths serve any site alike.
+export const SHARE_SITES = {
+ facebook: 'https://www.facebook.com/',
+ instagram: 'https://www.instagram.com/',
+ reddit: 'https://www.reddit.com/submit',
+ x: 'https://x.com/compose/post',
+} as const;
+
+export type ShareSite = keyof typeof SHARE_SITES;
+
+export type SharePath = 'sheet' | 'clipboard' | 'download';
+
+// The clipboard takes PNG in every engine — Chromium silently refuses a JPEG —
+// so the file is drawn to a canvas once. No resample: the bytes handed in are
+// already the size the visitor chose.
+async function pngOf(file: File): Promise {
+ const bitmap = await createImageBitmap(file);
+ const canvas = document.createElement('canvas');
+ canvas.width = bitmap.width;
+ canvas.height = bitmap.height;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) throw new Error('no 2d context');
+ ctx.drawImage(bitmap, 0, 0);
+ bitmap.close();
+ return await new Promise((resolve, reject) =>
+ canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png')
+ );
+}
+
+export async function sharePhoto(bytes: Uint8Array, filename: string, site: ShareSite): Promise {
+ const file = new File([bytes as BlobPart], filename, { type: 'image/jpeg' });
+ // A phone is the only place the picture reaches the target app on its own:
+ // the sheet hands the file over and that app owns its own composer.
+ if (navigator.canShare?.({ files: [file] })) {
+ try {
+ await navigator.share({ files: [file] });
+ } catch (err) {
+ // The sheet was closed: a "not now", not a failure, and nothing else opens
+ // on top of the choice the visitor just made.
+ if (!(err instanceof DOMException && err.name === 'AbortError')) throw err;
+ }
+ return 'sheet';
+ }
+ try {
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': await pngOf(file) })]);
+ window.open(SHARE_SITES[site], '_blank', 'noopener');
+ return 'clipboard';
+ } catch {
+ window.open(SHARE_SITES[site], '_blank', 'noopener');
+ download(bytes, filename);
+ return 'download';
+ }
+}
diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts
index c74429a..6b54233 100644
--- a/docker/frontend/src/i18n/en.ts
+++ b/docker/frontend/src/i18n/en.ts
@@ -219,6 +219,9 @@ export const en: Dict = {
'export.customLabel': 'Longest edge (px)',
'export.custom': 'EXPORT',
'export.cap': 'This account can export up to {px}px.',
+ 'export.share': 'Share to',
+ 'export.shareHint':
+ "On a phone the network's own app opens with the picture already in its composer. On a desktop the picture goes to the clipboard — paste it (Ctrl+V) into the composer that opened; where pasting is refused, the file downloads for you to drop in.",
'photo.uploading': 'UPLOADING…',
diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts
index bafd521..88e67ae 100644
--- a/docker/frontend/src/i18n/vi.ts
+++ b/docker/frontend/src/i18n/vi.ts
@@ -231,6 +231,9 @@ export const vi = {
'export.customLabel': 'Cạnh dài nhất (px)',
'export.custom': 'XUẤT',
'export.cap': 'Tài khoản này xuất tối đa {px}px.',
+ 'export.share': 'Chia sẻ lên',
+ 'export.shareHint':
+ 'Điện thoại: app của mạng mở ra với ảnh đã nằm trong ô đăng. Máy tính: ảnh được copy vào clipboard, dán (Ctrl+V) vào ô đăng vừa mở; nơi không cho dán thì ảnh được tải về để bạn kéo vào.',
'photo.uploading': 'ĐANG TẢI…',
diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css
index 9323ae9..a1fa246 100644
--- a/docker/frontend/src/styles/app.css
+++ b/docker/frontend/src/styles/app.css
@@ -1046,6 +1046,28 @@ button.hint:disabled { cursor: default; }
}
.export-custom .field { width: 96px; }
+/* Hand the export to a network. A hairline over it, so the row reads as the
+ second way out of the dialog and not as a sixth size. */
+.export-share {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--border);
+ text-align: left;
+}
+.export-share-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+.export-share-row .btn {
+ flex: 1 1 auto;
+ font-size: 12px;
+ letter-spacing: 0.06em;
+}
+
/* --- strip moderation (/admin) ------------------------------------------ */
.adm { height: 100%; overflow-y: auto; padding: 0 22px 40px; }
/* Sticky: the mark and the page name stay in the top-left corner while the
diff --git a/docker/frontend/src/ui/ExportModal.tsx b/docker/frontend/src/ui/ExportModal.tsx
index 7796e79..543f948 100644
--- a/docker/frontend/src/ui/ExportModal.tsx
+++ b/docker/frontend/src/ui/ExportModal.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useI18n } from '../i18n/I18nProvider';
+import { SHARE_SITES, type ShareSite } from '../engine/share';
// Export size. The photo on the stage has a longest edge; the three fixed
// answers name the sizes people ask for by name (2048/4096), the fourth takes a
@@ -12,6 +13,7 @@ export function ExportModal({
longest,
maxDim,
onPick,
+ onShare,
onClose,
}: {
/** Longest edge of the photo as it stands, crop included. */
@@ -19,6 +21,10 @@ export function ExportModal({
/** The tier's own ceiling, or null for none. */
maxDim: number | null;
onPick: (target: number | null) => void;
+ /** Hand the file straight to a network. Always the photo unchanged: the
+ sites resize to their own feed anyway, so an upscale here is pixels
+ bought for nothing. */
+ onShare: (site: ShareSite) => void;
onClose: () => void;
}) {
const { t } = useI18n();
@@ -85,6 +91,28 @@ export function ExportModal({
{t('export.cap', { px: maxDim })}
)}
+ {/* Straight to a network, no file on the way. What happens next is the
+ browser's to decide — see engine/share — so the line under the row
+ says which of the three it will be before the click. */}
+
+
{t('export.share')}
+
+ {(Object.keys(SHARE_SITES) as ShareSite[]).map((site) => (
+
+ ))}
+