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

249 lines
9.4 KiB
TypeScript
Raw Normal View History

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 );