2026-09-24 11:46:54 +02:00
'use strict' ;
const $ = ( id ) => document . getElementById ( id );
const el = ( tag , cls , text ) => {
const e = document . createElement ( tag );
if ( cls ) e . className = cls ;
if ( text != null ) e . textContent = text ;
return e ;
};
const store = {
get : ( k , d ) => { try { const v = localStorage . getItem ( k ); return v == null ? d : JSON . parse ( v ); } catch { return d ; } },
set : ( k , v ) => { try { localStorage . setItem ( k , JSON . stringify ( v )); } catch {} },
};
const monthFmt = new Intl . DateTimeFormat ( 'de-DE' , { month : 'long' , year : 'numeric' });
const dateFmt = new Intl . DateTimeFormat ( 'de-DE' , { dateStyle : 'medium' , timeStyle : 'short' });
const shortDate = new Intl . DateTimeFormat ( 'de-DE' , { month : 'short' , year : 'numeric' });
const collator = new Intl . Collator ( 'de' , { numeric : true , sensitivity : 'base' });
function fmtSize ( b ) {
if ( ! b ) return '' ;
const u = [ 'B' , 'KB' , 'MB' , 'GB' ];
let i = 0 ;
while ( b >= 1024 && i < u . length - 1 ) { b /= 1024 ; i ++ ; }
return ` ${ b . toFixed ( i > 1 ? 1 : 0 ). replace ( '.' , ',' ) } ${ u [ i ] } ` ;
}
// ------------------------------------------------------------------ Bild-URLs
const fileUrl = ( item ) => `nc://file/?h= ${ encodeURIComponent ( item . href ) } ` ;
const previewUrl = ( fileId , s ) => `nc://preview/?id= ${ encodeURIComponent ( fileId ) } &s= ${ s } ` ;
function thumbSize () {
const px = state . zoom * ( window . devicePixelRatio || 1 );
return px <= 256 ? 256 : px <= 512 ? 512 : 768 ;
}
function thumbUrl ( item ) {
if ( item . fileId && ( item . hasPreview || item . kind === 'video' )) return previewUrl ( item . fileId , thumbSize ());
if ( item . kind === 'image' ) return fileUrl ( item );
return null ;
}
// ------------------------------------------------------------------ Zustand
const state = {
source : store . get ( 'source' , 'timeline' ),
path : store . get ( 'path' , '' ),
2026-09-24 11:47:48 +02:00
sort : store . get ( 'sort' , 'new' ),
timelineOrder : store . get ( 'timelineOrder' , 'taken' ),
hideDng : store . get ( 'hideDng' , true ),
2026-09-24 11:46:54 +02:00
zoom : store . get ( 'zoom' , 180 ),
album : null , // { href, name } wenn ein Album offen ist
media : [], // aktuell sichtbare Bilder/Videos (für den Betrachter)
2026-09-24 11:47:48 +02:00
timeline : { offset : 0 , done : false , loading : false },
2026-09-24 11:46:54 +02:00
loadToken : 0 ,
};
// ------------------------------------------------------------------ Anmeldung
function showScreen ( name ) {
for ( const id of [ 'login' , 'connecting' , 'app' ]) $ ( id ). hidden = id !== name ;
}
function loginError ( msg ) {
$ ( 'login-error' ). textContent = msg || '' ;
$ ( 'login-error' ). hidden = ! msg ;
}
function setWaiting ( on ) {
$ ( 'waiting' ). hidden = ! on ;
$ ( 'browser-login' ). hidden = on ;
$ ( 'manual' ). hidden = on ;
$ ( 'server' ). disabled = on ;
}
async function afterLogin ( info ) {
$ ( 'acc-name' ). textContent = info . displayName ;
$ ( 'acc-server' ). textContent = info . server . replace ( /^https?:\/\// , '' );
showScreen ( 'app' );
selectSource ( state . source , true );
}
$ ( 'btn-browser' ). addEventListener ( 'click' , async () => {
loginError ();
setWaiting ( true );
try {
const info = await window . nc . loginBrowser ( $ ( 'server' ). value , $ ( 'remember' ). checked );
await afterLogin ( info );
} catch ( e ) {
loginError ( e . message );
} finally {
setWaiting ( false );
}
});
$ ( 'btn-cancel' ). addEventListener ( 'click' , () => window . nc . loginCancel ());
async function passwordLogin () {
loginError ();
const btn = $ ( 'btn-password' );
btn . disabled = true ;
try {
const info = await window . nc . loginPassword ( $ ( 'server' ). value , $ ( 'user' ). value . trim (), $ ( 'pass' ). value , $ ( 'remember' ). checked );
$ ( 'pass' ). value = '' ;
await afterLogin ( info );
} catch ( e ) {
loginError ( e . message );
} finally {
btn . disabled = false ;
}
}
$ ( 'btn-password' ). addEventListener ( 'click' , passwordLogin );
$ ( 'pass' ). addEventListener ( 'keydown' , ( e ) => { if ( e . key === 'Enter' ) passwordLogin (); });
$ ( 'server' ). addEventListener ( 'keydown' , ( e ) => { if ( e . key === 'Enter' ) $ ( 'btn-browser' ). click (); });
$ ( 'btn-logout' ). addEventListener ( 'click' , async () => {
await window . nc . logout ();
$ ( 'content' ). replaceChildren ();
showScreen ( 'login' );
});
// ------------------------------------------------------------------ Navigation
document . querySelectorAll ( '.sources button' ). forEach (( b ) =>
b . addEventListener ( 'click' , () => selectSource ( b . dataset . source )));
function selectSource ( source , initial = false ) {
state . source = source ;
store . set ( 'source' , source );
if ( ! initial ) state . album = null ;
document . querySelectorAll ( '.sources button' ). forEach (( b ) =>
b . setAttribute ( 'aria-current' , String ( b . dataset . source === source )));
load ();
}
function renderCrumbs () {
const c = $ ( 'crumbs' );
c . replaceChildren ();
const add = ( label , fn ) => {
if ( c . childElementCount ) c . append ( el ( 'span' , 'sep' , '› ' ));
const b = el ( 'button' , null , label );
if ( fn ) b . addEventListener ( 'click' , fn );
c . append ( b );
};
if ( state . source === 'timeline' ) add ( 'Alle Fotos' );
if ( state . source === 'albums' ) {
add ( 'Alben' , state . album ? () => { state . album = null ; selectSource ( 'albums' ); } : null );
if ( state . album ) add ( state . album . name );
}
if ( state . source === 'folder' ) {
add ( 'Dateien' , () => openFolder ( '' ));
const parts = state . path . split ( '/' ). filter ( Boolean );
parts . forEach (( p , i ) => add ( p , () => openFolder ( parts . slice ( 0 , i + 1 ). join ( '/' ))));
}
}
function openFolder ( path ) {
state . path = path . replace ( /^\/+|\/+$/g , '' );
store . set ( 'path' , state . path );
load ();
}
function goUp () {
if ( state . source === 'folder' && state . path ) openFolder ( state . path . split ( '/' ). slice ( 0 , - 1 ). join ( '/' ));
else if ( state . source === 'albums' && state . album ) { state . album = null ; selectSource ( 'albums' ); }
}
// ------------------------------------------------------------------ Laden
function notice ( title , text , action ) {
const n = el ( 'div' , 'notice' );
n . append ( el ( 'strong' , null , title ));
if ( text ) n . append ( el ( 'span' , null , text ));
if ( action ) {
const b = el ( 'button' , null , action . label );
b . addEventListener ( 'click' , action . fn );
n . append ( b );
}
return n ;
}
function errorNotice ( e ) {
const n = notice ( 'Das hat nicht geklappt' , e . message , { label : 'Erneut versuchen' , fn : load });
n . classList . add ( 'error' );
return n ;
}
async function load () {
const token = ++ state . loadToken ;
const content = $ ( 'content' );
content . replaceChildren ( el ( 'div' , 'loading-more' , 'Lade …' ));
content . scrollTop = 0 ;
state . media = [];
renderCrumbs ();
$ ( 'sort' ). hidden = ! ( state . source === 'folder' || ( state . source === 'albums' && state . album ));
2026-09-24 11:47:48 +02:00
$ ( 'tl-order' ). hidden = state . source !== 'timeline' ;
2026-09-24 11:46:54 +02:00
try {
if ( state . source === 'folder' ) {
const items = await window . nc . folder ( state . path );
if ( token !== state . loadToken ) return ;
renderFolder ( items );
} else if ( state . source === 'albums' ) {
if ( state . album ) {
const items = await window . nc . album ( state . album . href );
if ( token !== state . loadToken ) return ;
renderMediaList ( items , 'Dieses Album ist leer.' );
} else {
const res = await window . nc . albums ();
if ( token !== state . loadToken ) return ;
renderAlbums ( res );
}
} else {
2026-09-24 11:47:48 +02:00
startTimeline ( token , state . timelineOrder );
2026-09-24 11:46:54 +02:00
}
} catch ( e ) {
if ( token !== state . loadToken ) return ;
// Gespeicherter Ordner existiert nicht mehr: zurück zur Wurzel
if ( state . source === 'folder' && state . path && /Nicht gefunden/ . test ( e . message )) return openFolder ( '' );
content . replaceChildren ( errorNotice ( e ));
}
}
2026-09-24 11:47:48 +02:00
// DNG-Dateien (Rohdaten) optional ausblenden
function isDng ( item ) {
return /\.dng$/i . test ( item . name ) || /dng/i . test ( item . mime );
}
const isHidden = ( item ) => state . hideDng && isDng ( item );
const visibleMedia = ( items ) => items . filter (( i ) => ( i . kind === 'image' || i . kind === 'video' ) && ! isHidden ( i ));
2026-09-24 11:46:54 +02:00
function sortItems ( items ) {
const s = state . sort ;
2026-09-24 11:47:48 +02:00
const by = photoDate ;
2026-09-24 11:46:54 +02:00
const arr = [... items ];
if ( s === 'name' ) arr . sort (( a , b ) => collator . compare ( a . name , b . name ));
if ( s === 'new' ) arr . sort (( a , b ) => by ( b ) - by ( a ));
if ( s === 'old' ) arr . sort (( a , b ) => by ( a ) - by ( b ));
2026-09-24 11:47:48 +02:00
if ( s === 'mnew' ) arr . sort (( a , b ) => b . mtime - a . mtime );
if ( s === 'mold' ) arr . sort (( a , b ) => a . mtime - b . mtime );
2026-09-24 11:46:54 +02:00
return arr ;
}
// ------------------------------------------------------------------ Kacheln
function attachImg ( img , src , holder ) {
img . alt = '' ;
img . loading = 'lazy' ;
img . decoding = 'async' ;
img . draggable = false ;
img . addEventListener ( 'load' , () => img . classList . add ( 'loaded' ));
img . addEventListener ( 'error' , () => { holder . classList . add ( 'broken' ); img . remove (); });
img . src = src ;
}
function mediaTile ( item , index ) {
const b = el ( 'button' , 'tile' );
b . dataset . name = item . name ;
b . title = item . name ;
const src = thumbUrl ( item );
if ( src ) {
const img = el ( 'img' );
attachImg ( img , src , b );
b . append ( img );
} else {
b . classList . add ( 'broken' );
}
if ( item . kind === 'video' ) b . append ( el ( 'span' , 'badge' , '▶' ));
b . addEventListener ( 'click' , () => openViewer ( index ));
return b ;
}
function folderTile ( item ) {
const b = el ( 'button' , 'tile folder' );
const cover = el ( 'div' , 'cover' );
cover . append ( el ( 'div' , 'folder-shape' ));
b . append ( cover , el ( 'div' , 'label' , item . name ));
b . title = item . name ;
b . addEventListener ( 'click' , () => openFolder ( item . path ));
return b ;
}
2026-09-24 11:47:48 +02:00
function hiddenNote ( items ) {
const n = items . filter ( isHidden ). length ;
return n ? ` ${ n } DNG- ${ n === 1 ? 'Datei ist' : 'Dateien sind' } ausgeblendet.` : null ;
}
2026-09-24 11:46:54 +02:00
function renderMediaList ( items , emptyText ) {
2026-09-24 11:47:48 +02:00
const media = sortItems ( visibleMedia ( items ));
2026-09-24 11:46:54 +02:00
state . media = media ;
const content = $ ( 'content' );
2026-09-24 11:47:48 +02:00
if ( ! media . length ) { content . replaceChildren ( notice ( emptyText , hiddenNote ( items ))); return ; }
2026-09-24 11:46:54 +02:00
const grid = el ( 'div' , 'grid' );
media . forEach (( m , i ) => grid . append ( mediaTile ( m , i )));
content . replaceChildren ( grid );
}
function renderFolder ( items ) {
const dirs = items . filter (( i ) => i . isDir ). sort (( a , b ) => collator . compare ( a . name , b . name ));
2026-09-24 11:47:48 +02:00
const media = sortItems ( visibleMedia ( items ));
2026-09-24 11:46:54 +02:00
state . media = media ;
const content = $ ( 'content' );
content . replaceChildren ();
if ( dirs . length ) {
const g = el ( 'div' , 'grid folders' );
dirs . forEach (( d ) => g . append ( folderTile ( d )));
content . append ( g );
}
if ( media . length ) {
const g = el ( 'div' , 'grid' );
media . forEach (( m , i ) => g . append ( mediaTile ( m , i )));
content . append ( g );
}
if ( ! dirs . length && ! media . length ) {
2026-09-24 11:47:48 +02:00
content . append ( notice ( 'Hier liegen keine Fotos.' , hiddenNote ( items ) || ( state . path ? 'Über den Pfad oben kommst du zurück.' : null )));
2026-09-24 11:46:54 +02:00
}
}
function renderAlbums ({ available , albums }) {
const content = $ ( 'content' );
if ( ! available ) {
content . replaceChildren ( notice ( 'Alben sind nicht verfügbar.' , 'Dafür muss auf deiner Nextcloud die App „Fotos“ installiert sein. Die Ansichten „Alle Fotos“ und „Ordner“ funktionieren trotzdem.' ));
return ;
}
if ( ! albums . length ) {
content . replaceChildren ( notice ( 'Du hast noch keine Alben.' , 'Alben legst du in der Fotos-App deiner Nextcloud an.' ));
return ;
}
const g = el ( 'div' , 'grid folders' );
albums
. sort (( a , b ) => ( b . end || 0 ) - ( a . end || 0 ) || collator . compare ( a . name , b . name ))
. forEach (( a ) => {
const b = el ( 'button' , 'tile album' );
const cover = el ( 'div' , 'cover' );
if ( a . cover ) {
const img = el ( 'img' );
attachImg ( img , previewUrl ( a . cover , thumbSize ()), cover );
cover . append ( img );
} else {
cover . append ( el ( 'div' , 'folder-shape' ));
}
const bits = [ ` ${ a . count } ${ a . count === 1 ? 'Foto' : 'Fotos' } ` ];
if ( a . start ) bits . push ( shortDate . format ( a . start ));
if ( a . shared ) bits . push ( 'geteilt' );
b . append ( cover , el ( 'div' , 'label' , a . name ), el ( 'div' , 'sublabel' , bits . join ( ', ' )));
b . title = a . name ;
b . addEventListener ( 'click' , () => { state . album = { href : a . href , name : a . name }; load (); });
g . append ( b );
});
content . replaceChildren ( g );
}
// ------------------------------------------------------------------ Zeitleiste
let timelineObserver = null ;
2026-09-24 11:47:48 +02:00
function startTimeline ( token , order = 'taken' , fellBack = false ) {
$ ( 'tl-order' ). value = order ;
state . timeline = { offset : 0 , done : false , loading : false , token , order , months : new Map (), skipped : 0 };
state . media = [];
2026-09-24 11:46:54 +02:00
const content = $ ( 'content' );
content . replaceChildren ();
2026-09-24 11:47:48 +02:00
if ( fellBack ) {
content . append ( el ( 'p' , 'hint-line' ,
'Dein Server liefert keine Aufnahmedaten, darum ist nach dem Änderungsdatum der Dateien sortiert. Aufnahmedaten gibt es ab Nextcloud 28.' ));
}
2026-09-24 11:46:54 +02:00
const sentinel = el ( 'div' , 'sentinel' );
const more = el ( 'div' , 'loading-more' , 'Lade Fotos …' );
content . append ( sentinel , more );
if ( timelineObserver ) timelineObserver . disconnect ();
timelineObserver = new IntersectionObserver (( entries ) => {
if ( entries . some (( e ) => e . isIntersecting )) loadTimelinePage ();
}, { root : content , rootMargin : '1200px' });
timelineObserver . observe ( sentinel );
loadTimelinePage ();
}
2026-09-24 11:47:48 +02:00
// Datum, nach dem ein Foto einsortiert wird: Aufnahmedatum, sonst Dateidatum
const photoDate = ( item ) => item . taken || item . mtime ;
function monthSection ( t , date , sentinel ) {
const d = new Date ( date );
const key = ` ${ d . getFullYear () } - ${ d . getMonth () } ` ;
let m = t . months . get ( key );
if ( ! m ) {
const sec = el ( 'section' , 'month' );
const h = el ( 'h2' , null , monthFmt . format ( d ));
const countEl = el ( 'small' );
h . append ( countEl );
const grid = el ( 'div' , 'grid' );
sec . append ( h , grid );
sentinel . before ( sec );
m = { grid , countEl , count : 0 };
t . months . set ( key , m );
}
return m ;
}
2026-09-24 11:46:54 +02:00
async function loadTimelinePage () {
const t = state . timeline ;
if ( t . loading || t . done || t . token !== state . loadToken ) return ;
t . loading = true ;
const content = $ ( 'content' );
const sentinel = content . querySelector ( '.sentinel' );
const more = content . querySelector ( '.loading-more' );
try {
2026-09-24 11:47:48 +02:00
const res = await window . nc . timeline ( t . offset , t . order );
2026-09-24 11:46:54 +02:00
if ( t . token !== state . loadToken ) return ;
2026-09-24 11:47:48 +02:00
// Server kann nicht nach Aufnahmedatum sortieren, oder kein einziges Foto
// hat eines: auf Änderungsdatum umschalten
const noMetadata = t . order === 'taken' && t . offset === 0 && res . items . length && ! res . items . some (( i ) => i . taken );
if ( res . unsupported || noMetadata ) {
t . loading = false ;
startTimeline ( t . token , 'mtime' , true );
return ;
}
t . offset += res . items . length ;
t . done = res . done ;
for ( const item of res . items ) {
if ( isHidden ( item )) { t . skipped ++ ; continue ; }
const m = monthSection ( t , t . order === 'taken' ? photoDate ( item ) : item . mtime , sentinel );
2026-09-24 11:46:54 +02:00
const index = state . media . length ;
state . media . push ( item );
2026-09-24 11:47:48 +02:00
m . grid . append ( mediaTile ( item , index ));
m . count ++ ;
m . countEl . textContent = ` ${ m . count } ${ m . count === 1 ? 'Foto' : 'Fotos' } ` ;
2026-09-24 11:46:54 +02:00
}
2026-09-24 11:47:48 +02:00
if ( t . done ) {
2026-09-24 11:46:54 +02:00
more . remove ();
2026-09-24 11:47:48 +02:00
if ( ! state . media . length ) {
content . replaceChildren ( t . skipped
? notice ( 'Keine Fotos sichtbar.' , 'Alle gefundenen Dateien sind DNG-Dateien. Schalte „DNG ausblenden“ oben aus, um sie zu sehen.' )
: notice ( 'Keine Fotos gefunden.' , 'In deiner Nextcloud liegen noch keine Bilder oder Videos.' ));
}
2026-09-24 11:46:54 +02:00
}
} catch ( e ) {
if ( t . token !== state . loadToken ) return ;
t . done = true ;
more . replaceWith ( errorNotice ( e ));
} finally {
t . loading = false ;
}
// Falls der Bildschirm noch nicht gefüllt ist, direkt nachladen
if ( ! t . done && sentinel . isConnected && sentinel . getBoundingClientRect (). top < content . clientHeight + 1200 ) {
loadTimelinePage ();
}
}
// ------------------------------------------------------------------ Werkzeugleiste
2026-09-24 11:47:48 +02:00
$ ( 'tl-order' ). value = state . timelineOrder ;
$ ( 'tl-order' ). addEventListener ( 'change' , () => {
state . timelineOrder = $ ( 'tl-order' ). value ;
store . set ( 'timelineOrder' , state . timelineOrder );
load ();
});
2026-09-24 11:46:54 +02:00
$ ( 'sort' ). value = state . sort ;
$ ( 'sort' ). addEventListener ( 'change' , () => {
state . sort = $ ( 'sort' ). value ;
store . set ( 'sort' , state . sort );
load ();
});
2026-09-24 11:47:48 +02:00
$ ( 'hide-dng' ). checked = state . hideDng ;
$ ( 'hide-dng' ). addEventListener ( 'change' , () => {
state . hideDng = $ ( 'hide-dng' ). checked ;
store . set ( 'hideDng' , state . hideDng );
load ();
});
2026-09-24 11:46:54 +02:00
function applyZoom () {
document . documentElement . style . setProperty ( '--thumb' , ` ${ state . zoom } px` );
}
$ ( 'zoom' ). value = state . zoom ;
applyZoom ();
$ ( 'zoom' ). addEventListener ( 'input' , () => {
state . zoom = Number ( $ ( 'zoom' ). value );
store . set ( 'zoom' , state . zoom );
applyZoom ();
});
// ------------------------------------------------------------------ Betrachter
const viewer = {
index : 0 ,
original : false ,
token : 0 ,
idleTimer : null ,
};
function openViewer ( index ) {
viewer . index = index ;
viewer . original = false ;
$ ( 'viewer' ). hidden = false ;
showCurrent ();
wakeViewer ();
}
function closeViewer () {
$ ( 'viewer' ). hidden = true ;
const v = $ ( 'v-video' );
v . pause ();
v . removeAttribute ( 'src' );
v . load ();
$ ( 'v-img' ). removeAttribute ( 'src' );
// Fokus zurück auf die zuletzt gezeigte Kachel
const tiles = $ ( 'content' ). querySelectorAll ( '.grid:not(.folders) .tile' );
tiles [ viewer . index ] ? . focus ({ preventScroll : false });
}
function bigSize () {
const s = Math . max ( window . innerWidth , window . innerHeight ) * ( window . devicePixelRatio || 1 );
return s <= 1600 ? 1600 : s <= 2560 ? 2560 : 4096 ;
}
function preload ( item ) {
if ( ! item || item . kind !== 'image' || ! item . fileId || ! item . hasPreview ) return ;
const i = new Image ();
i . src = previewUrl ( item . fileId , bigSize ());
}
function showCurrent () {
const item = state . media [ viewer . index ];
if ( ! item ) return closeViewer ();
const token = ++ viewer . token ;
const img = $ ( 'v-img' );
const video = $ ( 'v-video' );
const msg = $ ( 'v-msg' );
msg . hidden = true ;
$ ( 'v-name' ). textContent = item . name ;
const meta = [ ` ${ viewer . index + 1 } von ${ state . media . length } ` ];
2026-09-24 11:47:48 +02:00
if ( item . taken ) meta . push ( `Aufgenommen ${ dateFmt . format ( item . taken ) } ` );
if ( item . mtime ) meta . push ( `Geändert ${ dateFmt . format ( item . mtime ) } ` );
2026-09-24 11:46:54 +02:00
if ( item . size ) meta . push ( fmtSize ( item . size ));
$ ( 'v-meta' ). replaceChildren (... meta . map (( m ) => el ( 'span' , null , m )));
$ ( 'v-prev' ). disabled = viewer . index === 0 ;
$ ( 'v-next' ). disabled = viewer . index >= state . media . length - 1 ;
$ ( 'v-open' ). hidden = ! item . fileId ;
$ ( 'v-original' ). hidden = item . kind !== 'image' ;
$ ( 'v-original' ). classList . toggle ( 'active' , viewer . original );
if ( item . kind === 'video' ) {
img . hidden = true ;
video . hidden = false ;
video . src = fileUrl ( item );
video . play (). catch (() => {});
return ;
}
video . pause ();
video . hidden = true ;
video . removeAttribute ( 'src' );
img . hidden = false ;
const canPreview = item . fileId && item . hasPreview ;
const target = viewer . original || ! canPreview ? fileUrl ( item ) : previewUrl ( item . fileId , bigSize ());
// Zuerst das (meist schon geladene) Vorschaubild zeigen, dann scharf nachladen
if ( canPreview ) {
img . src = previewUrl ( item . fileId , thumbSize ());
img . classList . add ( 'lowres' );
} else {
img . removeAttribute ( 'src' );
}
if ( viewer . original ) { msg . textContent = 'Original wird geladen …' ; msg . hidden = false ; }
const full = new Image ();
full . onload = () => {
if ( token !== viewer . token ) return ;
img . src = target ;
img . classList . remove ( 'lowres' );
msg . hidden = true ;
};
full . onerror = () => {
if ( token !== viewer . token ) return ;
img . classList . remove ( 'lowres' );
msg . textContent = viewer . original
? 'Dieses Dateiformat kann nur als Vorschau angezeigt werden.'
: 'Das Bild konnte nicht geladen werden.' ;
msg . hidden = false ;
};
full . src = target ;
preload ( state . media [ viewer . index + 1 ]);
preload ( state . media [ viewer . index - 1 ]);
// In der Zeitleiste rechtzeitig weitere Fotos holen
if ( state . source === 'timeline' && viewer . index > state . media . length - 10 ) loadTimelinePage ();
}
function step ( d ) {
const n = viewer . index + d ;
if ( n < 0 || n >= state . media . length ) return ;
viewer . index = n ;
viewer . original = false ;
showCurrent ();
}
function wakeViewer () {
const v = $ ( 'viewer' );
v . classList . remove ( 'idle' );
clearTimeout ( viewer . idleTimer );
viewer . idleTimer = setTimeout (() => v . classList . add ( 'idle' ), 2500 );
}
$ ( 'v-prev' ). addEventListener ( 'click' , () => step ( - 1 ));
$ ( 'v-next' ). addEventListener ( 'click' , () => step ( 1 ));
$ ( 'v-close' ). addEventListener ( 'click' , closeViewer );
$ ( 'v-full' ). addEventListener ( 'click' , () => window . nc . toggleFullscreen ());
$ ( 'v-original' ). addEventListener ( 'click' , () => { viewer . original = ! viewer . original ; showCurrent (); });
$ ( 'v-open' ). addEventListener ( 'click' , () => {
const item = state . media [ viewer . index ];
if ( item ? . fileId ) window . nc . openInNextcloud ( item . fileId );
});
$ ( 'viewer' ). addEventListener ( 'mousemove' , wakeViewer );
$ ( 'viewer' ). querySelector ( '.v-stage' ). addEventListener ( 'dblclick' , () => window . nc . toggleFullscreen ());
$ ( 'viewer' ). addEventListener ( 'wheel' , ( e ) => {
if ( Math . abs ( e . deltaY ) < 30 ) return ;
step ( e . deltaY > 0 ? 1 : - 1 );
}, { passive : true });
document . addEventListener ( 'keydown' , ( e ) => {
if ( ! $ ( 'viewer' ). hidden ) {
const k = e . key ;
if ( k === 'ArrowRight' || k === ' ' || k === 'PageDown' ) { e . preventDefault (); step ( 1 ); }
else if ( k === 'ArrowLeft' || k === 'PageUp' ) { e . preventDefault (); step ( - 1 ); }
else if ( k === 'Home' ) { viewer . index = 0 ; showCurrent (); }
else if ( k === 'End' ) { viewer . index = state . media . length - 1 ; showCurrent (); }
else if ( k === 'Escape' ) closeViewer ();
else if ( k === 'f' || k === 'F11' ) { e . preventDefault (); window . nc . toggleFullscreen (); }
else if ( k === 'o' ) $ ( 'v-original' ). click ();
wakeViewer ();
return ;
}
if ( $ ( 'app' ). hidden ) return ;
const typing = /INPUT|SELECT|TEXTAREA/ . test ( document . activeElement ? . tagName );
if ( e . key === 'Backspace' && ! typing ) { e . preventDefault (); goUp (); }
if ( e . key === 'F5' ) { e . preventDefault (); load (); }
});
document . addEventListener ( 'mouseup' , ( e ) => {
if ( e . button === 3 ) { // Maus-Zurück-Taste
if ( ! $ ( 'viewer' ). hidden ) closeViewer (); else goUp ();
}
});
// ------------------------------------------------------------------ Start
( async function start () {
const cfg = await window . nc . config ();
$ ( 'server' ). value = cfg . server ;
$ ( 'user' ). value = cfg . loginName ;
if ( cfg . hasSecret ) {
showScreen ( 'connecting' );
try {
const info = await window . nc . restore ();
if ( info ) return afterLogin ( info );
} catch ( e ) {
showScreen ( 'login' );
loginError ( e . message );
return ;
}
}
showScreen ( 'login' );
$ ( 'server' ). focus ();
})();