474 lines
16 KiB
TypeScript
474 lines
16 KiB
TypeScript
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, '&' ).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<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 );
|