rojects/source/components/project-list-default/project-list-default.ts

474 lines
16 KiB
TypeScript
Raw Normal View History

import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js';
declare global
{
interface Window
{
electronLocal?: {
openFolder: () => Promise<string | null>;
getRecents: () => Promise<string[]>;
removeRecent: ( p: string ) => Promise<string[]>;
};
}
}
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, '&amp;' ).replace( /"/g, '&quot;' );
}
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<void>
{
await this.render();
}
private async render(): Promise<void>
{
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 = `
<nav class="pld-nav">
<div class="pld-logo-wrap">
<img class="pld-logo" src="/rojos/roject-logo-570x240-1592x637.webp" alt="Roject">
</div>
<pld-tabs>
<pld-tab class="${ 'local' === this._activeTab ? 'active' : '' }" data-tab="local">This PC</pld-tab>
<pld-tab class="${ 'online' === this._activeTab ? 'active' : '' }" data-tab="online">Online</pld-tab>
</pld-tabs>
<a class="pld-user-group" href="${ logoutHref }">
<span class="pld-email">${ this.user?.email ?? '' }</span>
<span class="pld-logout-label">Log out</span>
</a>
</nav>
<main class="pld-main">
<div class="pld-list" id="pld-content"></div>
</main>
<div class="pld-members-overlay" style="display:none"></div>
<div class="pld-create-overlay" style="display:none"></div>
`;
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<void>
{
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<void>
{
const recents = await window.electronLocal!.getRecents();
container.innerHTML = `
<div class="pld-row new-project" id="pld-open-folder">
<div class="pld-badge-wrap"><div class="pld-badge"></div></div>
<span class="pld-name">Open Folder</span>
</div>
${ recents.map( r => this._recentFolderRowHtml( r ) ).join( '' ) }
${ recents.length === 0 ? '<p class="pld-no-recents">No recent folders</p>' : '' }
`;
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 `
<div class="pld-row pld-recent-row" data-path="${ escapeAttr( fp ) }" style="--project-color: ${ color }">
<div class="pld-badge-wrap">
<div class="pld-badge"></div>
<button class="pld-btn-remove" data-path="${ escapeAttr( fp ) }" title="Remove from recents">
<svg viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round">
<line x1="5" y1="5" x2="15" y2="15"/><line x1="15" y1="5" x2="5" y2="15"/>
</svg>
</button>
</div>
<span class="pld-name">${ folderName( fp ).toUpperCase() }</span>
</div>
`;
}
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<void>
{
this._isRemoteOnline = true;
const res = await fetch( this._projectUrl( '' ) );
const projects = res.ok ? await res.json() as Project[] : [];
container.innerHTML = `
<div class="pld-row new-project" id="pld-new-row">
<div class="pld-badge-wrap"><div class="pld-badge"></div></div>
<span class="pld-name">New Project</span>
</div>
${ projects.map( ( p, i ) => this.rowHtml( p, i ) ).join( '' ) }
`;
this.bindEvents( projects );
}
// ── Web (non-Electron): full render ─────────────────────────────────────
private async _renderFull(): Promise<void>
{
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 = `
<nav class="pld-nav">
<div class="pld-logo-wrap">
<img class="pld-logo" src="/rojos/roject-logo-570x240-1592x637.webp" alt="Roject">
</div>
<a class="pld-user-group" href="${ logoutHref }">
<span class="pld-email">${ this.user?.email ?? '' }</span>
<span class="pld-logout-label">Log out</span>
</a>
</nav>
<main class="pld-main">
<div class="pld-list">
<div class="pld-row new-project" id="pld-new-row">
<div class="pld-badge-wrap">
<div class="pld-badge"></div>
</div>
<span class="pld-name">New Project</span>
</div>
${ projects.map( ( p, i ) => this.rowHtml( p, i ) ).join( '' ) }
</div>
</main>
<div class="pld-members-overlay" style="display:none"></div>
<div class="pld-create-overlay" style="display:none"></div>
`;
this.bindEvents( projects );
}
// ── Shared helpers ──────────────────────────────────────────────────────
private rowHtml( p: Project, i: number ): string
{
const color = `hsl( ${ hueFromId( p.id ) }, 95%, 45% )`;
return `
<div class="pld-row" data-id="${ p.id }" data-name="${ escapeAttr( p.name ) }" data-owner="${ p.owner_id }"
style="--project-color: ${ color }; animation-delay: ${ 0.04 + i * 0.06 }s">
<div class="pld-badge-wrap">
<div class="pld-badge" style="animation-delay: ${ i * 0.45 }s"></div>
<button class="pld-btn-delete" data-id="${ p.id }" data-name="${ escapeAttr( p.name ) }" title="Delete">
<svg viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round">
<line x1="5" y1="5" x2="15" y2="15"/><line x1="15" y1="5" x2="5" y2="15"/>
</svg>
</button>
<button class="pld-btn-members" data-id="${ p.id }" title="Members">
<svg viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="1.8" stroke-linecap="round">
<circle cx="10" cy="7.5" r="3"/>
<path d="M3.5 17.5c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/>
</svg>
</button>
</div>
<span class="pld-name">${ p.name.toUpperCase() }</span>
</div>
`;
}
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 = `
<div class="pld-mp">
<h3 class="pld-mp-title">New Project</h3>
<form class="pld-cp-form">
<input class="pld-mp-input pld-cp-input" type="text" name="name"
placeholder="Project name" required autocomplete="off">
<div class="pld-cp-actions">
<button type="button" class="pld-mp-btn pld-cp-cancel">Cancel</button>
<button type="submit" class="pld-mp-btn">Create</button>
</div>
</form>
</div>
`;
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<void>
{
const overlay = this.querySelector( '.pld-members-overlay' ) as HTMLElement;
overlay.style.display = 'flex';
overlay.innerHTML = `<div class="pld-mp"><p class="pld-mp-loading">Loading…</p></div>`;
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 = `
<h3 class="pld-mp-title">${ row.dataset.name }</h3>
<ul class="pld-mp-list">
<li class="pld-mp-owner-row">
<span class="pld-mp-email">${ ownerLabel }</span>
<span class="pld-mp-role">owner</span>
</li>
${ members.map( m => `
<li>
<span class="pld-mp-email">${ m.member_id }</span>
<span class="pld-mp-role">${ m.role }</span>
${ isOwner ? `<button class="pld-mp-remove" data-pid="${ projectId }" data-mid="${ m.id }">Remove</button>` : '' }
</li>
` ).join( '' ) }
${ members.length === 0 ? '<li class="pld-mp-empty">No additional members</li>' : '' }
</ul>
${ isOwner ? `
<form class="pld-mp-add-form">
<input name="email" type="email" placeholder="Email address" class="pld-mp-input" required>
<select name="role" class="pld-mp-select">
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
<button type="submit" class="pld-mp-btn">Add</button>
</form>
` : '' }
<button class="pld-mp-close pld-mp-btn">Close</button>
`;
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 );