2026-10-05 02:25:02 +02:00
// Pithos in the browser: Drives on Silent Mode, opened with a key file.
// Everything is decrypted in this page; see lib/hosted.js for the protocol.
import * as Hosted from './lib/hosted.js' ;
const $ = ( s ) => document . querySelector ( s ) ;
const h = ( tag , attrs = { } , ... kids ) => {
const el = document . createElement ( tag ) ;
for ( const [ k , v ] of Object . entries ( attrs ) ) {
if ( v == null || v === false ) continue ;
if ( k . startsWith ( 'on' ) ) el . addEventListener ( k . slice ( 2 ) , v ) ;
else if ( k === 'class' ) el . className = v ;
else el . setAttribute ( k , v === true ? '' : v ) ;
}
for ( const c of kids . flat ( Infinity ) ) if ( c != null && c !== false ) el . append ( c instanceof Node ? c : String ( c ) ) ;
return el ;
} ;
const put = ( el , ... kids ) => { el . replaceChildren ( ) ; for ( const c of kids . flat ( Infinity ) ) if ( c != null && c !== false ) el . append ( c instanceof Node ? c : String ( c ) ) ; } ;
function toast ( msg , err ) {
const t = h ( 'div' , { class : ` toast ${ err ? ' err' : '' } ` } , msg ) ;
$ ( '#toasts' ) . append ( t ) ;
setTimeout ( ( ) => t . remove ( ) , err ? 7000 : 3500 ) ;
return t ;
}
const fail = ( e ) => toast ( e ? . message || String ( e ) , true ) ;
function fmtBytes ( n ) {
if ( ! Number . isFinite ( n ) ) return '' ;
const u = [ 'B' , 'KB' , 'MB' , 'GB' , 'TB' ] ;
let i = 0 ;
while ( n >= 1024 && i < u . length - 1 ) { n /= 1024 ; i ++ ; }
return ` ${ n < 10 && i ? n . toFixed ( 1 ) : Math . round ( n ) } ${ u [ i ] } ` ;
}
const fmtDate = ( s ) => { const d = new Date ( s ) ; return Number . isNaN ( + d ) ? '' : d . toLocaleString ( undefined , { dateStyle : 'medium' , timeStyle : 'short' } ) ; } ;
const baseName = ( p ) => p . replace ( /\/$/ , '' ) . split ( '/' ) . pop ( ) ;
const KIND = ( k ) => {
const e = k . split ( '.' ) . pop ( ) . toLowerCase ( ) ;
if ( /^(png|jpe?g|gif|webp|svg|avif)$/ . test ( e ) ) return 'image' ;
if ( /^(mp3|ogg|oga|wav|flac|m4a|opus)$/ . test ( e ) ) return 'audio' ;
if ( /^(mp4|webm|mov)$/ . test ( e ) ) return 'video' ;
if ( /^(txt|md|json|csv|log|xml|yml|yaml|ini|conf|js|css|html?)$/ . test ( e ) ) return 'text' ;
return 'file' ;
} ;
const ICON = { folder : '📁' , image : '🖼️' , audio : '🎵' , video : '🎬' , text : '📄' , file : '📦' , drive : '💽' } ;
// ---- session -----------------------------------------------------------------------------
const S = { key : null , token : null , drives : null , user : null , fixedBucket : null , label : '' } ;
async function signInWith ( text ) {
const key = await Hosted . readKeyFile ( text ) ;
if ( key . kind === 'owner' ) {
const s = await Hosted . signIn ( key . identitySeed ) ;
if ( s . state !== 'active' ) throw new Error ( s . state === 'empty' ? 'Nothing is on Silent Mode for this account yet. Move your drives there from Pithos first.' : 'This account is being moved; try again in a few minutes.' ) ;
const s3 = await Hosted . ownerS3 ( s . token ) ;
const token = s . token ;
let rulesCache = null ;
S . token = token ;
S . user = s3 . user ;
S . label = ` Signed in · ${ s . id . slice ( 0 , 8 ) } … ` ;
try { const me = await Hosted . api ( 'GET' , '/hosted/v1/me' , { token } ) ; if ( me . gate ? . name ) S . label = ` Signed in as ${ me . gate . name } ` ; } catch { }
S . drives = new Hosted . Drives ( {
accessKeyId : s3 . accessKeyId , secretKey : s3 . secretKey , master : key . encryptionKey ,
rules : async ( ) => {
if ( ! rulesCache || Date . now ( ) - rulesCache . at > 60_000 ) rulesCache = { at : Date . now ( ) , list : await Hosted . api ( 'GET' , '/hosted/v1/rules' , { token } ) . catch ( ( ) => [ ] ) } ;
return Array . isArray ( rulesCache . list ) ? rulesCache . list : rulesCache . list ? . rules || [ ] ;
} ,
} ) ;
} else {
if ( key . endpoint && new URL ( key . endpoint ) . host !== location . host && ! /silentmode\.st$/ . test ( new URL ( key . endpoint ) . hostname ) ) {
throw new Error ( ` This key is for ${ new URL ( key . endpoint ) . host } , not Drives on Silent Mode. Use it with Pithos or another S3 app. ` ) ;
}
S . drives = new Hosted . Drives ( { accessKeyId : key . accessKeyId , secretKey : key . secretKey } ) ;
S . fixedBucket = key . bucket || null ;
S . label = ` S3 key ${ key . accessKeyId . slice ( 0 , 6 ) } … ` ;
}
S . key = key ;
}
function signedIn ( ) {
$ ( '#signin' ) . hidden = true ;
$ ( '#app' ) . hidden = false ;
$ ( '#who' ) . textContent = S . label ;
$ ( '#who' ) . hidden = false ;
$ ( '#signout' ) . hidden = false ;
if ( ! location . hash || location . hash === '#/' ) location . hash = S . fixedBucket ? ` #/d/ ${ encodeURIComponent ( S . fixedBucket ) } / ` : '#/' ;
route ( ) ;
}
async function signOut ( ) {
if ( S . token ) await Hosted . signOut ( S . token ) ;
location . hash = '' ;
location . reload ( ) ;
}
// ---- sign-in screen --------------------------------------------------------------------------
async function showSignin ( ) {
$ ( '#app' ) . hidden = true ;
$ ( '#signin' ) . hidden = false ;
const saved = await Hosted . remembered ( ) ;
$ ( '#unlock' ) . hidden = ! saved ;
$ ( '#keyform' ) . hidden = ! ! saved ;
if ( saved ) $ ( '#unlock-pass' ) . focus ( ) ;
}
function signinError ( e ) {
const el = $ ( '#signin-error' ) ;
el . textContent = e ? . message || String ( e ) ;
el . hidden = false ;
}
async function trySignIn ( text , rememberPass ) {
$ ( '#signin-error' ) . hidden = true ;
const btns = [ $ ( '#go' ) , ... document . querySelectorAll ( '#unlock-form button' ) ] ;
btns . forEach ( ( b ) => { b . disabled = true ; } ) ;
try {
await signInWith ( text ) ;
if ( rememberPass ) { await Hosted . remember ( text , rememberPass ) ; }
signedIn ( ) ;
} catch ( e ) { signinError ( e ) ; } finally { btns . forEach ( ( b ) => { b . disabled = false ; } ) ; }
}
let pickedText = null ;
async function pickFile ( file ) {
if ( ! file ) return ;
if ( file . size > 64 * 1024 ) return signinError ( new Error ( 'That file is too big to be a key file.' ) ) ;
pickedText = await file . text ( ) ;
$ ( '#drop strong' ) . textContent = ` Key: ${ file . name } ` ;
}
function wireSignin ( ) {
const drop = $ ( '#drop' ) ;
drop . addEventListener ( 'click' , ( ) => $ ( '#keyfile' ) . click ( ) ) ;
drop . addEventListener ( 'keydown' , ( e ) => { if ( e . key === 'Enter' || e . key === ' ' ) { e . preventDefault ( ) ; $ ( '#keyfile' ) . click ( ) ; } } ) ;
$ ( '#keyfile' ) . addEventListener ( 'change' , ( ) => pickFile ( $ ( '#keyfile' ) . files [ 0 ] ) ) ;
drop . addEventListener ( 'dragover' , ( e ) => { e . preventDefault ( ) ; drop . classList . add ( 'over' ) ; } ) ;
drop . addEventListener ( 'dragleave' , ( ) => drop . classList . remove ( 'over' ) ) ;
drop . addEventListener ( 'drop' , ( e ) => { e . preventDefault ( ) ; drop . classList . remove ( 'over' ) ; pickFile ( e . dataTransfer . files [ 0 ] ) ; } ) ;
$ ( '#remember' ) . addEventListener ( 'change' , ( ) => { $ ( '#pass-row' ) . hidden = ! $ ( '#remember' ) . checked ; } ) ;
$ ( '#go' ) . addEventListener ( 'click' , ( ) => {
const text = pickedText || $ ( '#keytext' ) . value . trim ( ) ;
if ( ! text ) return signinError ( new Error ( 'Choose your key file first, or paste it.' ) ) ;
let pass = null ;
if ( $ ( '#remember' ) . checked ) {
pass = $ ( '#pass' ) . value ;
if ( pass . length < 10 ) return signinError ( new Error ( 'Use a passphrase of at least 10 characters to remember the key.' ) ) ;
}
trySignIn ( text , pass ) ;
} ) ;
$ ( '#unlock-form' ) . addEventListener ( 'submit' , async ( e ) => {
e . preventDefault ( ) ;
try {
const text = await Hosted . recall ( $ ( '#unlock-pass' ) . value ) ;
if ( ! text ) return showSignin ( ) ;
await trySignIn ( text , null ) ;
} catch ( err ) { signinError ( err ) ; }
} ) ;
$ ( '#forget' ) . addEventListener ( 'click' , async ( ) => {
await Hosted . forget ( ) ;
$ ( '#unlock' ) . hidden = true ;
$ ( '#keyform' ) . hidden = false ;
} ) ;
$ ( '#signout' ) . addEventListener ( 'click' , signOut ) ;
}
// ---- routes: #/ (drives) · #/d/<drive>/<folder>/ -----------------------------------------------
function current ( ) {
const m = /^#\/d\/([^/]+)\/(.*)$/ . exec ( location . hash ) ;
if ( ! m ) return { bucket : null , prefix : '' } ;
const prefix = m [ 2 ] . split ( '/' ) . map ( ( s ) => { try { return decodeURIComponent ( s ) ; } catch { return s ; } } ) . join ( '/' ) ;
return { bucket : decodeURIComponent ( m [ 1 ] ) , prefix } ;
}
const linkTo = ( bucket , prefix = '' ) => ` #/d/ ${ encodeURIComponent ( bucket ) } / ${ prefix . split ( '/' ) . map ( encodeURIComponent ) . join ( '/' ) } ` ;
let routeSeq = 0 ;
async function route ( ) {
if ( ! S . drives ) return ;
const seq = ++ routeSeq ;
const { bucket , prefix } = current ( ) ;
2026-10-07 23:06:33 +02:00
paintShared ( ) ;
2026-10-05 02:25:02 +02:00
if ( ! bucket ) return showDrives ( seq ) ;
return showFolder ( seq , bucket , prefix ) ;
}
function crumbs ( bucket , prefix ) {
const parts = [ ] ;
if ( ! S . fixedBucket ) parts . push ( h ( 'a' , { href : '#/' } , 'Drives' ) ) ;
if ( bucket ) {
if ( parts . length ) parts . push ( h ( 'span' , { class : 'sep' } , '› ' ) ) ;
parts . push ( h ( 'a' , { href : linkTo ( bucket ) } , ` ${ ICON . drive } ${ bucket } ` ) ) ;
let acc = '' ;
for ( const seg of prefix . split ( '/' ) . filter ( Boolean ) ) {
acc += seg + '/' ;
parts . push ( h ( 'span' , { class : 'sep' } , '› ' ) , h ( 'a' , { href : linkTo ( bucket , acc ) } , seg ) ) ;
}
}
put ( $ ( '#crumbs' ) , parts ) ;
}
async function showDrives ( seq ) {
crumbs ( null , '' ) ;
$ ( '#toolbar' ) . hidden = true ;
put ( $ ( '#list' ) , h ( 'p' , { class : 'empty' } , 'Loading…' ) ) ;
try {
const list = await S . drives . listBuckets ( ) ;
if ( seq !== routeSeq ) return ;
if ( ! list . length ) return put ( $ ( '#list' ) , h ( 'p' , { class : 'empty' } , 'No drives yet. Create one in Pithos.' ) ) ;
put ( $ ( '#list' ) , h ( 'table' , { class : 'files' } ,
h ( 'thead' , { } , h ( 'tr' , { } , h ( 'th' , { } , 'Drive' ) , h ( 'th' , { class : 'hide-sm' } , 'Created' ) ) ) ,
h ( 'tbody' , { } , list . map ( ( b ) => h ( 'tr' , { class : 'clickable' , onclick : ( ) => { location . hash = linkTo ( b . name ) ; } } ,
h ( 'td' , { } , h ( 'span' , { class : 'name' } , h ( 'span' , { class : 'ico' } , ICON . drive ) , h ( 'span' , { class : 'label' } , b . name ) ) ) ,
h ( 'td' , { class : 'num hide-sm' } , fmtDate ( b . created ) ) ) ) ) ) ) ;
$ ( '#count' ) . textContent = '' ;
} catch ( e ) {
if ( seq === routeSeq ) put ( $ ( '#list' ) , h ( 'p' , { class : 'empty error' } , e . message ) ) ;
}
}
async function showFolder ( seq , bucket , prefix ) {
crumbs ( bucket , prefix ) ;
$ ( '#toolbar' ) . hidden = false ;
put ( $ ( '#list' ) , h ( 'p' , { class : 'empty' } , 'Loading…' ) ) ;
let l ;
try {
l = await S . drives . list ( bucket , prefix ) ;
} catch ( e ) {
// a folder key may only list its own folder: go there
const only = /may only list (.+)$/ . exec ( e . message || '' ) ;
if ( only && ! prefix && seq === routeSeq ) { location . hash = linkTo ( bucket , only [ 1 ] ) ; return ; }
if ( seq === routeSeq ) put ( $ ( '#list' ) , h ( 'p' , { class : 'empty error' } , e . message ) ) ;
return ;
}
if ( seq !== routeSeq ) return ;
const rows = [ ] ;
for ( const f of l . folders ) {
rows . push ( h ( 'tr' , { class : 'clickable' , onclick : ( ) => { location . hash = linkTo ( bucket , f . path ) ; } } ,
h ( 'td' , { } , h ( 'span' , { class : 'name' } , h ( 'span' , { class : 'ico' } , ICON . folder ) , h ( 'span' , { class : 'label' } , baseName ( f . path ) ) , f . locked && h ( 'span' , { class : 'lock' } , 'encrypted' ) ) ) ,
h ( 'td' , { class : 'num hide-sm' } , '' ) , h ( 'td' , { class : 'num hide-sm' } , '' ) ,
h ( 'td' , { class : 'actions' , onclick : ( e ) => e . stopPropagation ( ) } ,
! S . fixedBucket || S . key . kind === 'owner' ? h ( 'button' , { class : 'btn small danger' , onclick : ( ) => deleteFolder ( bucket , f . path ) } , 'Delete' ) : null ) ) ) ;
}
for ( const o of l . files ) {
const kind = KIND ( o . key ) ;
const open = o . locked ? null : ( ) => view ( bucket , o ) ;
rows . push ( h ( 'tr' , { class : open ? 'clickable' : '' , onclick : open } ,
h ( 'td' , { } , h ( 'span' , { class : 'name' } , h ( 'span' , { class : 'ico' } , ICON [ kind ] ) , h ( 'span' , { class : 'label' , title : o . key } , baseName ( o . key ) ) , o . locked && h ( 'span' , { class : 'lock' } , 'encrypted' ) ) ) ,
h ( 'td' , { class : 'num hide-sm' } , fmtBytes ( o . size ) ) ,
h ( 'td' , { class : 'num hide-sm' } , fmtDate ( o . modified ) ) ,
h ( 'td' , { class : 'actions' , onclick : ( e ) => e . stopPropagation ( ) } ,
! o . locked && h ( 'button' , { class : 'btn small' , onclick : ( ) => download ( bucket , o . key ) } , 'Download' ) , ' ' ,
! o . locked && h ( 'button' , { class : 'btn small' , onclick : ( ) => rename ( bucket , o . key ) } , 'Rename' ) , ' ' ,
h ( 'button' , { class : 'btn small danger' , onclick : ( ) => remove ( bucket , o . key ) } , 'Delete' ) ) ) ) ;
}
$ ( '#count' ) . textContent = ` ${ l . folders . length } folders · ${ l . files . length } files ` ;
if ( ! rows . length ) return put ( $ ( '#list' ) , h ( 'p' , { class : 'empty' } , 'This folder is empty. Drop files here or press Upload.' ) ) ;
put ( $ ( '#list' ) , h ( 'table' , { class : 'files' } ,
h ( 'thead' , { } , h ( 'tr' , { } , h ( 'th' , { } , 'Name' ) , h ( 'th' , { class : 'hide-sm' } , 'Size' ) , h ( 'th' , { class : 'hide-sm' } , 'Modified' ) , h ( 'th' , { } ) ) ) ,
h ( 'tbody' , { } , rows ) ) ) ;
}
// ---- actions ------------------------------------------------------------------------------------------
async function uploadFiles ( files ) {
const { bucket , prefix } = current ( ) ;
if ( ! bucket || ! files . length ) return ;
for ( const file of files ) {
const bar = h ( 'progress' , { max : file . size || 1 , value : 0 } ) ;
const t = h ( 'div' , { class : 'toast' } , ` Uploading ${ file . name } ` , bar ) ;
$ ( '#toasts' ) . append ( t ) ;
try {
await S . drives . write ( bucket , prefix + file . name , file , ( n ) => { bar . value = n ; } ) ;
t . replaceChildren ( ` Uploaded ${ file . name } ` ) ;
} catch ( e ) { t . classList . add ( 'err' ) ; t . replaceChildren ( ` ${ file . name } : ${ e . message } ` ) ; }
setTimeout ( ( ) => t . remove ( ) , 4000 ) ;
}
route ( ) ;
}
async function newFolder ( ) {
const { bucket , prefix } = current ( ) ;
const name = ( prompt ( 'Folder name' ) || '' ) . trim ( ) ;
if ( ! name ) return ;
if ( name . includes ( '/' ) ) return fail ( new Error ( 'A folder name cannot contain /' ) ) ;
try { await S . drives . mkdir ( bucket , ` ${ prefix } ${ name } / ` ) ; route ( ) ; } catch ( e ) { fail ( e ) ; }
}
2026-10-07 23:06:33 +02:00
async function saveBlob ( blob , name ) {
if ( ANDROID ) return saveToPhone ( blob , name ) ;
2026-10-05 02:25:02 +02:00
const url = URL . createObjectURL ( blob ) ;
const a = h ( 'a' , { href : url , download : name } ) ;
document . body . append ( a ) ;
a . click ( ) ;
a . remove ( ) ;
setTimeout ( ( ) => URL . revokeObjectURL ( url ) , 60_000 ) ;
}
async function download ( bucket , key ) {
const t = toast ( ` Preparing ${ baseName ( key ) } … ` ) ;
2026-10-07 23:06:33 +02:00
try { await saveBlob ( await S . drives . blob ( bucket , key ) , baseName ( key ) ) ; t . remove ( ) ; } catch ( e ) { t . remove ( ) ; fail ( e ) ; }
2026-10-05 02:25:02 +02:00
}
async function rename ( bucket , key ) {
const name = ( prompt ( 'New name' , baseName ( key ) ) || '' ) . trim ( ) ;
if ( ! name || name === baseName ( key ) ) return ;
if ( name . includes ( '/' ) ) return fail ( new Error ( 'A name cannot contain /' ) ) ;
const t = toast ( ` Renaming ${ baseName ( key ) } … ` ) ;
try { await S . drives . rename ( bucket , key , key . slice ( 0 , key . length - baseName ( key ) . length ) + name ) ; t . remove ( ) ; route ( ) ; } catch ( e ) { t . remove ( ) ; fail ( e ) ; }
}
async function remove ( bucket , key ) {
if ( ! confirm ( ` Delete ${ baseName ( key ) } ? This cannot be undone. ` ) ) return ;
try { await S . drives . remove ( bucket , key ) ; toast ( 'Deleted' ) ; route ( ) ; } catch ( e ) { fail ( e ) ; }
}
async function deleteFolder ( bucket , prefix ) {
if ( ! confirm ( ` Delete the folder ${ baseName ( prefix ) } and everything in it? This cannot be undone. ` ) ) return ;
const t = toast ( ` Deleting ${ baseName ( prefix ) } … ` ) ;
try { await S . drives . removeFolder ( bucket , prefix ) ; t . remove ( ) ; toast ( 'Folder deleted' ) ; route ( ) ; } catch ( e ) { t . remove ( ) ; fail ( e ) ; }
}
// ---- viewer ---------------------------------------------------------------------------------------------
let viewerUrl = null ;
async function view ( bucket , o ) {
const kind = KIND ( o . key ) ;
if ( kind === 'file' ) return download ( bucket , o . key ) ;
const body = $ ( '#viewer-body' ) ;
$ ( '#viewer-name' ) . textContent = baseName ( o . key ) ;
put ( body , h ( 'p' , { class : 'muted' } , 'Opening…' ) ) ;
$ ( '#viewer' ) . hidden = false ;
$ ( '#viewer-dl' ) . onclick = ( ) => download ( bucket , o . key ) ;
try {
let src ;
if ( ! o . encrypted && ( kind === 'audio' || kind === 'video' ) ) src = await S . drives . presigned ( bucket , o . storageKey ) ;
else {
if ( kind !== 'text' && o . size > 300 * 1024 * 1024 ) throw new Error ( 'This file is too large to open in the browser; download it instead.' ) ;
const blob = await S . drives . blob ( bucket , o . key ) ;
if ( kind === 'text' ) {
if ( blob . size > 2 * 1024 * 1024 ) throw new Error ( 'This text file is too large to show; download it instead.' ) ;
return put ( body , h ( 'pre' , { } , await blob . text ( ) ) ) ;
}
viewerUrl = URL . createObjectURL ( blob ) ;
src = viewerUrl ;
}
if ( kind === 'image' ) put ( body , h ( 'img' , { src , alt : baseName ( o . key ) } ) ) ;
else if ( kind === 'audio' ) put ( body , h ( 'audio' , { src , controls : true , autoplay : true } ) ) ;
else put ( body , h ( 'video' , { src , controls : true , autoplay : true } ) ) ;
} catch ( e ) { put ( body , h ( 'p' , { class : 'error' } , e . message ) ) ; }
}
function closeViewer ( ) {
$ ( '#viewer' ) . hidden = true ;
put ( $ ( '#viewer-body' ) ) ;
if ( viewerUrl ) { URL . revokeObjectURL ( viewerUrl ) ; viewerUrl = null ; }
}
2026-10-07 23:06:33 +02:00
// ---- Pithos for Android -------------------------------------------------------------------------------
// The Android app runs this page in a WebView and adds window.PithosAndroid:
// downloads are written to the phone's Downloads folder, and files shared
// to Pithos from another app wait here until a folder is open to take them.
// Bytes cross as base64 in 1 MB pieces (the bridge only passes strings).
const ANDROID = window . PithosAndroid || null ;
const PIECE = 1 << 20 ;
const blobToB64 = ( blob ) => new Promise ( ( resolve , reject ) => {
const r = new FileReader ( ) ;
r . onload = ( ) => resolve ( String ( r . result ) . slice ( String ( r . result ) . indexOf ( ',' ) + 1 ) ) ;
r . onerror = ( ) => reject ( r . error ) ;
r . readAsDataURL ( blob ) ;
} ) ;
async function saveToPhone ( blob , name ) {
const id = ANDROID . saveBegin ( name , blob . type || '' ) ;
if ( ! id ) throw new Error ( 'Could not save to Downloads.' ) ;
try {
for ( let off = 0 ; off < blob . size ; off += PIECE ) {
if ( ! ANDROID . saveChunk ( id , await blobToB64 ( blob . slice ( off , off + PIECE ) ) ) ) throw new Error ( 'Could not write to Downloads.' ) ;
}
if ( ! ANDROID . saveEnd ( id ) ) throw new Error ( 'Could not finish saving to Downloads.' ) ;
} catch ( e ) { ANDROID . saveAbort ( id ) ; throw e ; }
}
function readShared ( ) {
const files = [ ] ;
for ( let i = 0 ; i < ANDROID . sharedCount ( ) ; i ++ ) {
let info = { } ;
try { info = JSON . parse ( ANDROID . sharedInfo ( i ) ) ; } catch { }
const parts = [ ] ;
for ( let b64 ; ( b64 = ANDROID . sharedRead ( i , PIECE ) ) ; ) parts . push ( Uint8Array . from ( atob ( b64 ) , ( c ) => c . charCodeAt ( 0 ) ) ) ;
files . push ( new File ( parts , info . name || ` shared- ${ i + 1 } ` , { type : info . type || '' } ) ) ;
}
ANDROID . sharedDone ( ) ;
return files ;
}
// A bar above the list while shared files wait: "Upload here" once a folder is open.
function paintShared ( ) {
$ ( '#shared-bar' ) ? . remove ( ) ;
if ( ! ANDROID || ! S . drives ) return ;
const n = ANDROID . sharedCount ( ) ;
if ( ! n ) return ;
const { bucket } = current ( ) ;
const what = n === 1 ? '1 file' : ` ${ n } files ` ;
const bar = h ( 'div' , { class : 'shared-bar' , id : 'shared-bar' } ,
h ( 'span' , { } , bucket ? ` ${ what } shared to Pithos. ` : ` ${ what } shared to Pithos. Open the folder to put them in. ` ) ,
bucket && h ( 'button' , { class : 'btn small primary' , onclick : ( ) => { bar . remove ( ) ; uploadFiles ( readShared ( ) ) ; } } , 'Upload here' ) ,
h ( 'button' , { class : 'btn small' , onclick : ( ) => { ANDROID . sharedDone ( ) ; bar . remove ( ) ; } } , 'Discard' ) ) ;
$ ( '#list' ) . before ( bar ) ;
}
2026-10-05 02:25:02 +02:00
// ---- boot ------------------------------------------------------------------------------------------------
function wireApp ( ) {
$ ( '#upload' ) . addEventListener ( 'click' , ( ) => $ ( '#files' ) . click ( ) ) ;
$ ( '#files' ) . addEventListener ( 'change' , ( ) => { uploadFiles ( [ ... $ ( '#files' ) . files ] ) ; $ ( '#files' ) . value = '' ; } ) ;
$ ( '#newfolder' ) . addEventListener ( 'click' , newFolder ) ;
const list = $ ( '#list' ) ;
list . addEventListener ( 'dragover' , ( e ) => { if ( current ( ) . bucket ) { e . preventDefault ( ) ; list . classList . add ( 'over' ) ; } } ) ;
list . addEventListener ( 'dragleave' , ( ) => list . classList . remove ( 'over' ) ) ;
list . addEventListener ( 'drop' , ( e ) => { e . preventDefault ( ) ; list . classList . remove ( 'over' ) ; uploadFiles ( [ ... e . dataTransfer . files ] ) ; } ) ;
$ ( '#viewer-close' ) . addEventListener ( 'click' , closeViewer ) ;
document . addEventListener ( 'keydown' , ( e ) => { if ( e . key === 'Escape' && ! $ ( '#viewer' ) . hidden ) closeViewer ( ) ; } ) ;
window . addEventListener ( 'hashchange' , route ) ;
2026-10-07 23:06:33 +02:00
window . addEventListener ( 'pithos-shared' , paintShared ) ;
2026-10-05 02:25:02 +02:00
}
wireSignin ( ) ;
wireApp ( ) ;
showSignin ( ) . catch ( fail ) ;