import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js'; declare global { interface Window { electronLocal?: { openFolder: () => Promise; getRecents: () => Promise; removeRecent: ( p: string ) => Promise; }; } } const AUTH_HOST = 'https://account.rokojori.com'; const APP_URL = 'https://roject.rokojori.com'; interface JwtUser { userId: string; email: string; } interface Project { id: string; name: string; owner_id: string; } interface ProjectMember { id: string; project_id: string; member_type: 'user' | 'group'; member_id: string; role: string; } function hueFromId( id: string ): number { let h = 0; for ( let i = 0; i < id.length; i++ ) { h = ( h * 31 + id.charCodeAt( i ) ) % 360; } return Math.abs( h ); } function escapeAttr( s: string ): string { return s.replace( /&/g, '&' ).replace( /"/g, '"' ); } function folderName( folderPath: string ): string { const parts = folderPath.split( /[\\/]/ ).filter( Boolean ); return parts[ parts.length - 1 ] ?? folderPath; } class ProjectListDefault extends HTMLElement { user: JwtUser | null = null; _activeTab: 'local' | 'online' = 'local'; _isRemoteOnline = false; private _projectUrl( path: string ): string { return this._isRemoteOnline ? `/api/remote/projects${ path }` : `/api/projects${ path }`; } async connectedCallback(): Promise { await this.render(); } private async render(): Promise { if ( window.electronLocal ) { this._renderElectronShell(); await this._renderTabContent(); return; } await this._renderFull(); } // ── Electron: shell (nav + empty content + overlays) ──────────────────── private _renderElectronShell(): void { const logoutHref = `${ AUTH_HOST }/api/auth/logout?redirect=${ encodeURIComponent( APP_URL ) }`; this.innerHTML = `
`; this.querySelectorAll( 'pld-tab' ).forEach( tab => { tab.addEventListener( 'click', async () => { const t = ( tab as HTMLElement ).dataset.tab as 'local' | 'online'; if ( t === this._activeTab ) return; this._activeTab = t; this.querySelectorAll( 'pld-tab' ).forEach( el => el.classList.remove( 'active' ) ); tab.classList.add( 'active' ); await this._renderTabContent(); } ); } ); } private async _renderTabContent(): Promise { const content = this.querySelector( '#pld-content' ) as HTMLElement; if ( 'local' === this._activeTab ) { await this._renderLocalContent( content ); } else { await this._renderOnlineContent( content ); } } // ── Electron: local tab ───────────────────────────────────────────────── private async _renderLocalContent( container: HTMLElement ): Promise { const recents = await window.electronLocal!.getRecents(); container.innerHTML = `
Open Folder…
${ recents.map( r => this._recentFolderRowHtml( r ) ).join( '' ) } ${ recents.length === 0 ? '

No recent folders

' : '' } `; container.querySelector( '#pld-open-folder' )!.addEventListener( 'click', async () => { const fp = await window.electronLocal!.openFolder(); if ( null != fp ) this._openLocalFolder( fp ); } ); container.querySelectorAll( '.pld-recent-row' ).forEach( row => { row.addEventListener( 'click', ( e: Event ) => { if ( ( e.target as HTMLElement ).closest( '.pld-btn-remove' ) ) return; this._openLocalFolder( ( row as HTMLElement ).dataset.path! ); } ); } ); container.querySelectorAll( '.pld-btn-remove' ).forEach( btn => { btn.addEventListener( 'click', async ( e: Event ) => { e.stopPropagation(); await window.electronLocal!.removeRecent( ( btn as HTMLElement ).dataset.path! ); await this._renderLocalContent( container ); } ); } ); } private _recentFolderRowHtml( fp: string ): string { const color = `hsl( ${ hueFromId( fp ) }, 95%, 45% )`; return `
${ folderName( fp ).toUpperCase() }
`; } private _openLocalFolder( fp: string ): void { const name = folderName( fp ); location.href = `/editor.html?localRoot=${ encodeURIComponent( fp ) }&name=${ encodeURIComponent( name ) }`; } // ── Electron: online tab ──────────────────────────────────────────────── private async _renderOnlineContent( container: HTMLElement ): Promise { this._isRemoteOnline = true; const res = await fetch( this._projectUrl( '' ) ); const projects = res.ok ? await res.json() as Project[] : []; container.innerHTML = `
New Project…
${ projects.map( ( p, i ) => this.rowHtml( p, i ) ).join( '' ) } `; this.bindEvents( projects ); } // ── Web (non-Electron): full render ───────────────────────────────────── private async _renderFull(): Promise { this._isRemoteOnline = false; const res = await fetch( '/api/projects' ); const projects = res.ok ? await res.json() as Project[] : []; const logoutHref = `${ AUTH_HOST }/api/auth/logout?redirect=${ encodeURIComponent( APP_URL ) }`; this.innerHTML = `
New Project…
${ projects.map( ( p, i ) => this.rowHtml( p, i ) ).join( '' ) }
`; this.bindEvents( projects ); } // ── Shared helpers ────────────────────────────────────────────────────── private rowHtml( p: Project, i: number ): string { const color = `hsl( ${ hueFromId( p.id ) }, 95%, 45% )`; return `
${ p.name.toUpperCase() }
`; } private bindEvents( projects: Project[] ): void { this.querySelector( '#pld-new-row' )!.addEventListener( 'click', () => this.openCreateDialog() ); this.querySelectorAll( '.pld-row:not(.new-project)' ).forEach( row => { row.addEventListener( 'click', ( e: Event ) => { if ( ( e.target as HTMLElement ).closest( '.pld-btn-delete, .pld-btn-members' ) ) return; const el = row as HTMLElement; const param = this._isRemoteOnline ? 'remoteProject' : 'project'; location.href = `/editor.html?${ param }=${ el.dataset.id }&name=${ encodeURIComponent( el.dataset.name! ) }`; } ); } ); this.querySelectorAll( '.pld-btn-delete' ).forEach( btn => { btn.addEventListener( 'click', async ( e: Event ) => { e.stopPropagation(); const el = btn as HTMLElement; const ok = await showConfirmDialog( { icon: '🗑', title: 'Delete Project', message: `Delete "${ el.dataset.name }"? This cannot be undone.`, confirmLabel: 'Delete', cancelLabel: 'Cancel', danger: true } ); if ( !ok ) return; await fetch( this._projectUrl( `/${ el.dataset.id }` ), { method: 'DELETE' } ); if ( window.electronLocal ) { await this._renderOnlineContent( this.querySelector( '#pld-content' ) as HTMLElement ); } else { await this._renderFull(); } } ); } ); this.querySelectorAll( '.pld-btn-members' ).forEach( btn => { btn.addEventListener( 'click', ( e: Event ) => { e.stopPropagation(); const id = ( btn as HTMLElement ).dataset.id!; const row = this.querySelector( `.pld-row[data-id="${ id }"]` ) as HTMLElement; this.openMembersPanel( id, row ); } ); } ); } private openCreateDialog(): void { const overlay = this.querySelector( '.pld-create-overlay' ) as HTMLElement; overlay.style.display = 'flex'; overlay.innerHTML = `

New Project

`; const panel = overlay.querySelector( '.pld-mp' )!; panel.addEventListener( 'click', e => e.stopPropagation() ); overlay.addEventListener( 'click', () => { overlay.style.display = 'none'; }, { once: true } ); const input = overlay.querySelector( '.pld-cp-input' ) as HTMLInputElement; setTimeout( () => input.focus(), 30 ); overlay.querySelector( '.pld-cp-cancel' )!.addEventListener( 'click', () => { overlay.style.display = 'none'; } ); overlay.querySelector( '.pld-cp-form' )!.addEventListener( 'submit', async ( e: Event ) => { e.preventDefault(); const name = input.value.trim(); if ( !name ) return; overlay.style.display = 'none'; const res = await fetch( this._projectUrl( '' ), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( { name } ) } ); if ( res.ok ) { if ( window.electronLocal ) { await this._renderOnlineContent( this.querySelector( '#pld-content' ) as HTMLElement ); } else { await this._renderFull(); } } } ); } private async openMembersPanel( projectId: string, row: HTMLElement ): Promise { const overlay = this.querySelector( '.pld-members-overlay' ) as HTMLElement; overlay.style.display = 'flex'; overlay.innerHTML = `

Loading…

`; const panel = overlay.querySelector( '.pld-mp' )!; panel.addEventListener( 'click', e => e.stopPropagation() ); overlay.addEventListener( 'click', () => { overlay.style.display = 'none'; }, { once: true } ); const res = await fetch( this._projectUrl( `/${ projectId }/members` ) ); const members = res.ok ? await res.json() as ProjectMember[] : []; const ownerId = row.dataset.owner ?? ''; const isOwner = this.user?.userId === ownerId; const ownerLabel = isOwner ? this.user!.email : ownerId; panel.innerHTML = `

${ row.dataset.name }

  • ${ ownerLabel } owner
  • ${ members.map( m => `
  • ${ m.member_id } ${ m.role } ${ isOwner ? `` : '' }
  • ` ).join( '' ) } ${ members.length === 0 ? '
  • No additional members
  • ' : '' }
${ isOwner ? `
` : '' } `; panel.querySelector( '.pld-mp-close' )!.addEventListener( 'click', () => { overlay.style.display = 'none'; } ); panel.querySelectorAll( '.pld-mp-remove' ).forEach( btn => { btn.addEventListener( 'click', async () => { const el = btn as HTMLElement; await fetch( this._projectUrl( `/${ el.dataset.pid }/members/${ el.dataset.mid }` ), { method: 'DELETE' } ); await this.openMembersPanel( projectId, row ); } ); } ); const addForm = panel.querySelector( '.pld-mp-add-form' ) as HTMLFormElement | null; addForm?.addEventListener( 'submit', async ( e: Event ) => { e.preventDefault(); const data = Object.fromEntries( new FormData( e.target as HTMLFormElement ) ); await fetch( this._projectUrl( `/${ projectId }/members` ), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( data ) } ); await this.openMembersPanel( projectId, row ); } ); } } customElements.define( 'project-list-default', ProjectListDefault );