Repository Public
1// A share link, as seen by a visitor without an account. Opening the link counts one view on the server and sets a2// short-lived grant cookie; everything after that is re-checked by the server on each request.3import { formatBytes, type PreviewKind } from '@spd/shared';4import { type FormEvent, useCallback, useEffect, useRef, useState } from 'react';5import { ApiError, get, post } from '../api.ts';6import { Art } from '../art.tsx';7import { Glyph } from '../glyphs.tsx';8import { fmtDate, getLocale, useT } from '../i18n.ts';9import { Thumb } from '../preview/Thumb.tsx';10import { Viewer } from '../preview/Viewer.tsx';11import type { PreviewSource } from '../types.ts';12import { Button, cx, IconButton, ProgressBar, Spinner } from '../ui.tsx';13import { Notice, PField, PublicShell } from './PublicShell.tsx';14import { store, tokenKey } from './util.ts';1516interface PubFile { id: string; name: string; size: number; kind: PreviewKind; updatedAt: string; thumb: boolean }17interface PubFolder { id: string; name: string; updatedAt: string }18type Opened = { label: string; owner: string; canDownload: boolean; expiresAt: string | null } & ({ type: 'file'; file: PubFile } | { type: 'folder'; root: { id: string; name: string } });19interface FolderListing { folder: { id: string; name: string }; breadcrumbs: { id: string; name: string }[]; folders: PubFolder[]; files: PubFile[] }20type State = { kind: 'loading' } | { kind: 'unavailable' } | { kind: 'error'; tooMany?: boolean } | { kind: 'gate'; needsPassword: boolean; locked: boolean } | { kind: 'open'; share: Opened };21/** What a failed call means for the page: the link died, the grant expired, or something else. */22type Fate = 'unavailable' | 'locked' | 'other';23const fateOf = (e: unknown): Fate => (e instanceof ApiError && e.code === 'share_unavailable' ? 'unavailable' : e instanceof ApiError && e.code === 'share_locked' ? 'locked' : 'other');2425const base = (token: string) => `/api/v1/public/shares/${token}`;26const sourceOf = (token: string, f: PubFile, canDownload: boolean): PreviewSource => ({27 id: f.id, name: f.name, size: f.size, kind: f.kind, canDownload,28 contentUrl: (o) => `${base(token)}/files/${f.id}/content${o?.download ? '?download=1' : o?.text ? '?as=text' : ''}`,29 previewUrl: (k) => `${base(token)}/files/${f.id}/preview/${k}`,30});31const KIND_ICON: Partial<Record<PreviewKind, string>> = { image: 'image', heic: 'image', svg: 'image', video: 'video', audio: 'audio', archive: 'archive', sheet: 'sheet', csv: 'sheet', text: 'text', markdown: 'text', json: 'text' };32/** Above this size a download is handed to the browser directly instead of being read in memory first. */33const BLOB_LIMIT = 300 * 1024 * 1024;3435// One "open" per page load, even if React mounts the page twice (development) or the visitor double-submits.36const opening = new Map<string, Promise<Opened>>();37const openOnce = (token: string, password?: string): Promise<Opened> => {38 const k = `${token}|${password ?? ''}`;39 let p = opening.get(k);40 if (!p) { p = post<Opened>(`/public/shares/${token}/open`, password ? { password } : {}); opening.set(k, p); void p.catch(() => opening.delete(k)); }41 return p;42};4344export function SharePage({ token }: { token: string }) {45 const t = useT();46 const key = tokenKey('spd.share', token);47 const [state, setState] = useState<State>({ kind: 'loading' });48 const [round, setRound] = useState(0);4950 const enter = useCallback((share: Opened) => { store.set(key, share); setState({ kind: 'open', share }); }, [key]);51 /** A call failed in the middle of the visit: revoked or expired link → neutral state; grant expired → reopen. */52 const fail = useCallback((e: unknown): Fate => {53 const f = fateOf(e);54 if (f === 'unavailable') { store.del(key); setState({ kind: 'unavailable' }); }55 if (f === 'locked') { store.del(key); opening.clear(); setState((s) => ({ kind: 'gate', needsPassword: s.kind === 'gate' ? s.needsPassword : false, locked: true })); void get<{ needsPassword: boolean }>(`/public/shares/${token}`).then((r) => setState((s) => (s.kind === 'gate' ? { ...s, needsPassword: r.needsPassword } : s)), (x) => { if (fateOf(x) === 'unavailable') setState({ kind: 'unavailable' }); }); }56 return f;57 }, [key, token]);5859 useEffect(() => {60 let live = true;61 void (async () => {62 try {63 const { needsPassword } = await get<{ needsPassword: boolean }>(`/public/shares/${token}`);64 // a reload of the tab while the grant is still valid does not count a second view65 const cached = store.get<Opened>(key);66 if (cached) {67 try {68 if (cached.type === 'folder') await get(`/public/shares/${token}/list`);69 else await probe(token, cached.file.id);70 if (live) setState({ kind: 'open', share: cached });71 return;72 } catch (e) { store.del(key); if (fateOf(e) === 'unavailable') throw e; }73 }74 if (needsPassword) { if (live) setState({ kind: 'gate', needsPassword: true, locked: false }); return; }75 const share = await openOnce(token);76 if (live) enter(share);77 } catch (e) {78 if (!live) return;79 setState(e instanceof ApiError && e.status === 404 ? { kind: 'unavailable' } : { kind: 'error', tooMany: e instanceof ApiError && e.status === 429 });80 }81 })();82 return () => { live = false; };83 }, [token, key, round, enter]);8485 const share = state.kind === 'open' ? state.share : null;86 const title = share ? share.label || (share.type === 'file' ? share.file.name : share.root.name) : t('public.share.title');87 return (88 <PublicShell title={title} wide={share?.type === 'folder'}>89 {state.kind === 'loading' ? <div className="flex justify-center py-24 text-ink-3"><Spinner size={28} /></div>90 : state.kind === 'unavailable' ? <Notice icon="link" title={t('public.share.unavailable')} text={t('public.share.unavailableText')} />91 : state.kind === 'error' ? <Notice icon="warning" tone="warn" title={state.tooMany ? t('public.err.tooMany') : t('public.err.network')} action={<Button size="lg" icon="refresh" onClick={() => { setState({ kind: 'loading' }); setRound((n) => n + 1); }}>{t('public.retry')}</Button>} />92 : state.kind === 'gate' ? <Gate token={token} needsPassword={state.needsPassword} locked={state.locked} onOpen={enter} onGone={() => setState({ kind: 'unavailable' })} />93 : state.share.type === 'file' ? <FileShare token={token} share={state.share} file={state.share.file} fail={fail} />94 : <FolderShare token={token} share={state.share} rootId={state.share.root.id} fail={fail} />}95 </PublicShell>96 );97}9899/** Is the file still reachable? One byte of content is asked for; nothing is counted. */100async function probe(token: string, fileId: string): Promise<void> {101 const res = await fetch(`${base(token)}/files/${fileId}/content`, { headers: { Range: 'bytes=0-0' }, credentials: 'same-origin' }).catch(() => { throw new ApiError(0, 'network', ''); });102 if (res.ok) { void res.body?.cancel().catch(() => {}); return; }103 const body = await res.json().catch(() => null);104 if (body?.error === 'preview_unsupported') return; // alive, simply not displayable inline105 throw new ApiError(res.status, body?.error ?? 'http_error', body?.message ?? '');106}107108function Gate({ token, needsPassword, locked, onOpen, onGone }: { token: string; needsPassword: boolean; locked: boolean; onOpen: (s: Opened) => void; onGone: () => void }) {109 const t = useT();110 const [password, setPassword] = useState('');111 const [error, setError] = useState<string | null>(null);112 const [busy, setBusy] = useState(false);113 const submit = async (e: FormEvent) => {114 e.preventDefault();115 if (needsPassword && !password) { setError(t('public.err.passwordRequired')); return; }116 setBusy(true); setError(null);117 try { onOpen(await openOnce(token, needsPassword ? password : undefined)); }118 catch (x) {119 setBusy(false);120 const c = x instanceof ApiError ? x : new ApiError(0, 'network', '');121 if (c.status === 404) onGone();122 else setError(c.code === 'password_invalid' ? t('public.err.passwordInvalid') : c.code === 'password_required' ? t('public.err.passwordRequired') : c.status === 429 ? t('public.err.tooMany') : c.status === 0 ? t('public.err.network') : t('public.err.generic'));123 }124 };125 return (126 <form onSubmit={submit} noValidate className="mx-auto max-w-sm card px-6 py-8" data-testid="gate">127 <div className="flex justify-center"><Art name={locked ? 'clock' : 'lock'} size={112} /></div>128 <h1 className="mt-3 text-center text-2xl font-bold tracking-tight">{locked ? t('public.share.locked') : t('public.share.protected')}</h1>129 <p className="mt-2 text-center text-sm leading-relaxed text-ink-2">{locked ? t('public.share.lockedText') : t('public.share.protectedText')}</p>130 <div className="mt-5 space-y-4">131 {needsPassword ? <PField label={t('public.password')} type="password" autoComplete="off" autoFocus value={password} onChange={(e) => { setPassword(e.target.value); setError(null); }} error={error} /> : error ? <p role="alert" className="text-sm text-danger">{error}</p> : null}132 <Button type="submit" variant="primary" size="lg" busy={busy} className="w-full">{locked && !needsPassword ? t('public.share.reopen') : t('public.share.open')}</Button>133 </div>134 </form>135 );136}137138// ---------------------------------------------------------------- downloads ----------------------------------------------------------------139type Dl = { id: string; pct: number | null } | null;140/**141 * Download through fetch so that a refusal (limit reached, link revoked, grant expired) is explained on the page instead142 * of the browser showing the server's raw answer. Very large files go to the browser's own download manager.143 */144function useDownload(fail: (e: unknown) => Fate) {145 const t = useT();146 const [active, setActive] = useState<Dl>(null);147 const [message, setMessage] = useState<string | null>(null);148 const run = async (id: string, url: string, name: string, size: number | null) => {149 if (active) return;150 setMessage(null);151 if (size === null || size > BLOB_LIMIT) { window.location.assign(url); return; }152 setActive({ id, pct: null });153 try {154 const res = await fetch(url, { credentials: 'same-origin' }).catch(() => { throw new ApiError(0, 'network', ''); });155 if (!res.ok) { const b = await res.json().catch(() => null); throw new ApiError(res.status, b?.error ?? 'http_error', b?.message ?? ''); }156 const total = Number(res.headers.get('content-length')) || size || 0;157 const parts: BlobPart[] = []; let got = 0;158 const reader = res.body?.getReader();159 if (reader) for (;;) { const { done, value } = await reader.read(); if (done) break; parts.push(value); got += value.byteLength; if (total) setActive({ id, pct: Math.min(99, Math.floor((got / total) * 100)) }); }160 else parts.push(await res.blob());161 const blobUrl = URL.createObjectURL(new Blob(parts, { type: 'application/octet-stream' }));162 const a = document.createElement('a'); a.href = blobUrl; a.download = name; a.rel = 'noopener'; document.body.appendChild(a); a.click(); a.remove();163 setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);164 } catch (e) {165 if (fail(e) === 'other') {166 const c = e instanceof ApiError ? e : new ApiError(0, 'network', '');167 setMessage(c.code === 'download_limit' ? t('public.share.downloadLimit') : c.code === 'download_forbidden' ? t('public.share.downloadForbidden') : c.code === 'empty_selection' ? t('public.share.emptyZip')168 : c.status === 404 ? t('public.share.fileGone') : c.status === 429 ? t('public.err.tooMany') : c.status === 0 ? t('public.err.network') : t('public.err.generic'));169 }170 } finally { setActive(null); }171 };172 return { active, message, setMessage, run };173}174function Alert({ text, onClose }: { text: string | null; onClose: () => void }) {175 const t = useT();176 return text ? (177 <div role="alert" className="flex items-start gap-3 rounded-2xl bg-warn-soft px-4 py-3 text-sm text-warn" data-testid="share-alert">178 <Glyph name="warning" size={18} className="mt-px shrink-0" /><p className="min-w-0 flex-1">{text}</p>179 <button type="button" onClick={onClose} aria-label={t('public.share.dismiss')} className="-m-1 shrink-0 rounded p-1"><Glyph name="x" size={16} /></button>180 </div>181 ) : null;182}183function ShareHead({ share, name, icon, sub }: { share: Opened; name: string; icon: string; sub?: string }) {184 const t = useT();185 return (186 <header className="flex items-start gap-4">187 <div className="btn-grad flex h-14 w-14 shrink-0 items-center justify-center rounded-[18px]"><Glyph name={icon} size={26} /></div>188 <div className="min-w-0 flex-1">189 <h1 className="text-2xl leading-tight font-bold tracking-tight break-words">{name}</h1>190 {share.label && share.label !== name ? <p className="mt-0.5 text-sm break-words text-ink-2">{share.label}</p> : null}191 <p className="mt-1 text-sm text-ink-3">{[sub, share.owner ? t('public.share.sharedBy', { owner: share.owner }) : '', share.expiresAt ? t('public.share.expires', { date: fmtDate(share.expiresAt) }) : ''].filter(Boolean).join(' · ')}</p>192 {share.canDownload ? null : <p className="mt-2 inline-flex items-center gap-1.5 rounded-full bg-hover px-2.5 py-1 text-xs font-medium text-ink-2" title={t('public.share.viewOnlyText')}><Glyph name="eye" size={14} />{t('public.share.viewOnly')}</p>}193 </div>194 </header>195 );196}197198// ---------------------------------------------------------------- one file ----------------------------------------------------------------199function FileShare({ token, share, file, fail }: { token: string; share: Opened; file: PubFile; fail: (e: unknown) => Fate }) {200 const t = useT();201 const loc = getLocale();202 const [viewing, setViewing] = useState(false);203 const [checking, setChecking] = useState(false);204 const dl = useDownload(fail);205 const src = sourceOf(token, file, share.canDownload);206 const open = async () => {207 setChecking(true);208 try { await probe(token, file.id); setViewing(true); } catch (e) { if (fail(e) === 'other') dl.setMessage(e instanceof ApiError && e.status === 404 ? t('public.share.fileGone') : t('public.err.network')); } finally { setChecking(false); }209 };210 return (211 <div className="mx-auto max-w-xl space-y-4" data-testid="file-share">212 <section className="card p-5 sm:p-6">213 <ShareHead share={share} name={file.name} icon={KIND_ICON[file.kind] ?? 'file'} sub={formatBytes(file.size, loc)} />214 <button type="button" onClick={open} aria-label={`${t('public.share.preview')} — ${file.name}`}215 className="press mt-5 flex aspect-[4/3] w-full items-center justify-center overflow-hidden rounded-[22px] bg-bg ring-1 ring-line ring-inset">216 <Thumb src={file.thumb ? src.previewUrl('thumb') : undefined} name={file.name} kind={file.kind} className="h-full w-full rounded-none!" />217 </button>218 <div className="mt-4 flex flex-col gap-2 sm:flex-row">219 <Button size="lg" variant={share.canDownload ? 'secondary' : 'primary'} icon="eye" className="sm:flex-1" busy={checking} onClick={open}>{t('public.share.preview')}</Button>220 {share.canDownload ? <Button size="lg" variant="primary" icon="download" className="sm:flex-1" busy={!!dl.active} onClick={() => void dl.run(file.id, src.contentUrl({ download: true }), file.name, file.size)}>{dl.active?.pct != null ? t('public.share.downloading', { pct: dl.active.pct }) : t('public.share.download')}</Button> : null}221 </div>222 {dl.active?.pct != null ? <div className="mt-3"><ProgressBar value={dl.active.pct / 100} label={t('public.share.downloadName', { name: file.name })} /></div> : null}223 </section>224 <Alert text={dl.message} onClose={() => dl.setMessage(null)} />225 {viewing ? <Viewer source={src} onClose={() => setViewing(false)} /> : null}226 </div>227 );228}229230// ---------------------------------------------------------------- a folder ----------------------------------------------------------------231const hashFolder = () => { const h = window.location.hash.slice(1); return /^[0-9a-f-]{36}$/i.test(h) ? h : null; };232function FolderShare({ token, share, rootId, fail }: { token: string; share: Opened; rootId: string; fail: (e: unknown) => Fate }) {233 const t = useT();234 const loc = getLocale();235 const [folderId, setFolderId] = useState<string | null>(hashFolder);236 const [listing, setListing] = useState<FolderListing | null>(null);237 const [loading, setLoading] = useState(true);238 const [grid, setGrid] = useState(() => window.matchMedia('(min-width: 640px)').matches);239 const [viewIndex, setViewIndex] = useState<number | null>(null);240 const dl = useDownload(fail);241 const failRef = useRef(fail); failRef.current = fail;242 const setMessage = dl.setMessage;243244 useEffect(() => { const h = () => setFolderId(hashFolder()); window.addEventListener('hashchange', h); return () => window.removeEventListener('hashchange', h); }, []);245 const go = (id: string) => { window.location.hash = id === rootId ? '' : id; setFolderId(id === rootId ? null : id); };246 const load = useCallback(async (id: string | null, quiet = false): Promise<boolean> => {247 if (!quiet) setLoading(true);248 try { setListing(await get<FolderListing>(`/public/shares/${token}/list${id ? `?folder=${id}` : ''}`)); return true; }249 catch (e) {250 if (failRef.current(e) === 'other') {251 if (e instanceof ApiError && e.status === 404 && id) { setMessage(t('public.share.folderGone')); window.location.hash = ''; setFolderId(null); } // outside the share, or deleted since252 else setMessage(e instanceof ApiError && e.status === 429 ? t('public.err.tooMany') : t('public.err.network'));253 }254 return false;255 } finally { setLoading(false); }256 }, [token, setMessage, t]);257 useEffect(() => { setViewIndex(null); void load(folderId); }, [folderId, load]);258259 const files = listing?.files ?? [], folders = listing?.folders ?? [];260 /** Re-ask the server before showing a file: a revoked link must not appear to work. */261 const openFile = async (i: number) => { if (await load(folderId, true)) setViewIndex(i); };262 const viewed = viewIndex !== null ? files[viewIndex] : undefined;263 const hasSub = folders.length > 0, sum = files.reduce((n, f) => n + f.size, 0);264 const zip = () => void dl.run('zip', `${base(token)}/zip${listing && listing.folder.id !== rootId ? `?folder=${listing.folder.id}` : ''}`, `${listing?.folder.name ?? 'dossier'}.zip`, hasSub ? null : sum);265266 return (267 <div className="space-y-4" data-testid="folder-share">268 <section className="card p-5 sm:p-6">269 <ShareHead share={share} name={share.type === 'folder' ? share.root.name : ''} icon="folder" />270 </section>271 <Alert text={dl.message} onClose={() => dl.setMessage(null)} />272 <section className="card">273 <div className="flex flex-wrap items-center gap-2 border-b border-line px-3 py-2 sm:px-4">274 <nav aria-label={t('public.share.breadcrumb')} className="min-w-0 flex-1">275 <ol className="flex flex-wrap items-center gap-0.5 text-sm" data-testid="crumbs">276 {(listing?.breadcrumbs ?? [{ id: rootId, name: share.type === 'folder' ? share.root.name : '' }]).map((c, i, all) => (277 <li key={c.id} className="flex min-w-0 items-center gap-0.5">278 {i ? <Glyph name="chevronRight" size={14} className="shrink-0 text-ink-3" /> : null}279 {i === all.length - 1 ? <span aria-current="page" className="truncate px-1.5 py-1.5 font-semibold">{c.name}</span>280 : <button type="button" onClick={() => go(c.id)} className="max-w-40 truncate rounded-lg px-1.5 py-1.5 text-ink-2 hover:bg-hover hover:text-ink">{c.name}</button>}281 </li>282 ))}283 </ol>284 </nav>285 <div className="flex shrink-0 items-center gap-1">286 <IconButton icon={grid ? 'list' : 'grid'} label={grid ? t('public.share.viewList') : t('public.share.viewGrid')} onClick={() => setGrid((g) => !g)} />287 {share.canDownload && (files.length || folders.length) ? <Button icon="download" busy={dl.active?.id === 'zip'} onClick={zip} data-testid="zip">{dl.active?.id === 'zip' && dl.active.pct != null ? t('public.share.downloading', { pct: dl.active.pct }) : t('public.share.downloadAll')}</Button> : null}288 </div>289 </div>290 {loading && !listing ? <div className="flex justify-center py-16 text-ink-3"><Spinner /></div>291 : !files.length && !folders.length ? <p className="px-6 py-16 text-center text-sm text-ink-3">{t('public.share.emptyFolder')}</p>292 : grid ? (293 <ul className={cx('grid grid-cols-2 gap-3 p-3 sm:grid-cols-3 sm:p-4 lg:grid-cols-4', loading && 'opacity-60')} data-testid="entries">294 {folders.map((d) => (295 <li key={d.id}><button type="button" onClick={() => go(d.id)} className="press flex w-full flex-col overflow-hidden rounded-[18px] bg-bg text-left ring-1 ring-line ring-inset" data-entry="folder">296 <span className="flex aspect-[4/3] w-full items-center justify-center text-accent"><Thumb name={d.name} kind="folder" className="h-14 w-14" /></span>297 <span className="truncate border-t border-line bg-surface px-3 py-2.5 text-sm font-medium">{d.name}</span>298 </button></li>299 ))}300 {files.map((f, i) => (301 <li key={f.id} className="relative overflow-hidden rounded-[18px] bg-bg ring-1 ring-line ring-inset" data-entry="file">302 <button type="button" onClick={() => void openFile(i)} aria-label={t('public.share.openItem', { name: f.name })} className="flex w-full flex-col text-left">303 <span className="flex aspect-[4/3] w-full items-center justify-center overflow-hidden text-ink-3"><Thumb src={f.thumb ? sourceOf(token, f, false).previewUrl('thumb') : undefined} name={f.name} kind={f.kind} className="h-full w-full rounded-none!" /></span>304 <span className={cx('block w-full border-t border-line bg-surface py-2 pl-3', share.canDownload ? 'pr-11' : 'pr-3')}><span className="block truncate text-sm font-medium">{f.name}</span><span className="block text-xs text-ink-3">{formatBytes(f.size, loc)}</span></span>305 </button>306 {share.canDownload ? <IconButton icon="download" size={18} label={t('public.share.downloadName', { name: f.name })} disabled={!!dl.active} className="absolute right-0.5 bottom-1" onClick={() => void dl.run(f.id, sourceOf(token, f, true).contentUrl({ download: true }), f.name, f.size)} /> : null}307 </li>308 ))}309 </ul>310 ) : (311 <ul className={cx('divide-y divide-line', loading && 'opacity-60')} data-testid="entries">312 {folders.map((d) => (313 <li key={d.id}><button type="button" onClick={() => go(d.id)} className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-hover" data-entry="folder">314 <Thumb name={d.name} kind="folder" className="h-10 w-10" /><span className="min-w-0 flex-1 truncate text-[15px] font-medium">{d.name}</span><Glyph name="chevronRight" size={18} className="shrink-0 text-ink-3" />315 </button></li>316 ))}317 {files.map((f, i) => (318 <li key={f.id} className="flex items-center hover:bg-hover" data-entry="file">319 <button type="button" onClick={() => void openFile(i)} aria-label={t('public.share.openItem', { name: f.name })} className="flex min-w-0 flex-1 items-center gap-3 py-3 pl-4 text-left">320 <Thumb src={f.thumb ? sourceOf(token, f, false).previewUrl('thumb') : undefined} name={f.name} kind={f.kind} className="h-10 w-10" />321 <span className="min-w-0 flex-1"><span className="block truncate text-[15px] font-medium">{f.name}</span><span className="block text-xs text-ink-3">{formatBytes(f.size, loc)} · {t('public.share.modified', { date: fmtDate(f.updatedAt, { dateStyle: 'medium' }) })}</span></span>322 </button>323 {share.canDownload ? <IconButton icon="download" label={t('public.share.downloadName', { name: f.name })} disabled={!!dl.active} className="mx-1 h-11 w-11" onClick={() => void dl.run(f.id, sourceOf(token, f, true).contentUrl({ download: true }), f.name, f.size)} /> : <span className="w-3" />}324 </li>325 ))}326 </ul>327 )}328 {listing && (files.length || folders.length) ? <p className="border-t border-line px-4 py-2.5 text-xs text-ink-3">{t('public.share.items', { n: files.length + folders.length })}</p> : null}329 </section>330 {viewed ? <Viewer source={sourceOf(token, viewed, share.canDownload)} onClose={() => setViewIndex(null)}331 onPrev={viewIndex! > 0 ? () => setViewIndex(viewIndex! - 1) : undefined} onNext={viewIndex! < files.length - 1 ? () => setViewIndex(viewIndex! + 1) : undefined} /> : null}332 </div>333 );334}335