249 lines
9.4 KiB
TypeScript
249 lines
9.4 KiB
TypeScript
|
|
import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js';
|
||
|
|
|
||
|
|
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 );
|
||
|
|
}
|
||
|
|
|
||
|
|
class ProjectListDefault extends HTMLElement {
|
||
|
|
user: JwtUser | null = null;
|
||
|
|
|
||
|
|
async connectedCallback(): Promise<void> {
|
||
|
|
await this.render();
|
||
|
|
}
|
||
|
|
|
||
|
|
private async render(): Promise<void> {
|
||
|
|
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 );
|
||
|
|
}
|
||
|
|
|
||
|
|
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="${ 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="${ 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;
|
||
|
|
location.href = `/editor.html?project=${ 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( `/api/projects/${ el.dataset.id }`, { method: 'DELETE' } );
|
||
|
|
await this.render();
|
||
|
|
} );
|
||
|
|
} );
|
||
|
|
|
||
|
|
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( '/api/projects', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify( { name } )
|
||
|
|
} );
|
||
|
|
if ( res.ok ) await this.render();
|
||
|
|
} );
|
||
|
|
}
|
||
|
|
|
||
|
|
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( `/api/projects/${ 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( `/api/projects/${ 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( `/api/projects/${ projectId }/members`, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify( data )
|
||
|
|
} );
|
||
|
|
await this.openMembersPanel( projectId, row );
|
||
|
|
} );
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
customElements.define( 'project-list-default', ProjectListDefault );
|