history: local filesystem access, remote proxy, file-tree custom elements, project-list tabs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
6eaa1d8685
commit
99a8f591e9
|
|
@ -1,4 +1,4 @@
|
||||||
import { app, BrowserWindow, ipcMain, session } from 'electron';
|
import { app, BrowserWindow, dialog, ipcMain, session } from 'electron';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import https from 'https';
|
import https from 'https';
|
||||||
|
|
@ -63,6 +63,35 @@ function clearCredentials(): void {
|
||||||
try { fs.unlinkSync( passwordFile() ); } catch { /* already gone */ }
|
try { fs.unlinkSync( passwordFile() ); } catch { /* already gone */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function localRecentsFile(): string {
|
||||||
|
return path.join( app.getPath( 'userData' ), 'local-recents.json' );
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadLocalRecents(): string[] {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync( localRecentsFile(), 'utf-8' );
|
||||||
|
return JSON.parse( raw ) as string[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveLocalRecents( recents: string[] ): void {
|
||||||
|
fs.writeFileSync( localRecentsFile(), JSON.stringify( recents ), 'utf-8' );
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLocalRecent( folderPath: string ): void {
|
||||||
|
const recents = loadLocalRecents().filter( r => r !== folderPath );
|
||||||
|
recents.unshift( folderPath );
|
||||||
|
saveLocalRecents( recents.slice( 0, 10 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLocalRecent( folderPath: string ): string[] {
|
||||||
|
const recents = loadLocalRecents().filter( r => r !== folderPath );
|
||||||
|
saveLocalRecents( recents );
|
||||||
|
return recents;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Network ────────────────────────────────────────────────────────────────────
|
// ── Network ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function postJson( url: string, body: unknown ): Promise<unknown> {
|
function postJson( url: string, body: unknown ): Promise<unknown> {
|
||||||
|
|
@ -140,6 +169,7 @@ async function createMainWindow(): Promise<void> {
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
|
preload: path.join( __dirname, 'preload.js' ),
|
||||||
},
|
},
|
||||||
title: 'Roject',
|
title: 'Roject',
|
||||||
} );
|
} );
|
||||||
|
|
@ -182,6 +212,7 @@ function loadEnv(): void {
|
||||||
function startExpressServer(): void {
|
function startExpressServer(): void {
|
||||||
loadEnv();
|
loadEnv();
|
||||||
process.env.ROJECT_ROOT = path.join( __dirname, '..', '..' );
|
process.env.ROJECT_ROOT = path.join( __dirname, '..', '..' );
|
||||||
|
process.env.ROJECT_ELECTRON = 'true';
|
||||||
const serverPath = path.join( __dirname, '..', 'server', 'server', 'index.js' );
|
const serverPath = path.join( __dirname, '..', 'server', 'server', 'index.js' );
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const { startServer } = require( serverPath ) as { startServer: ( port: number ) => void };
|
const { startServer } = require( serverPath ) as { startServer: ( port: number ) => void };
|
||||||
|
|
@ -217,6 +248,22 @@ app.whenReady().then( () => {
|
||||||
ipcMain.handle( 'auth:last-password', () => loadLastPassword() );
|
ipcMain.handle( 'auth:last-password', () => loadLastPassword() );
|
||||||
ipcMain.handle( 'auth:clear-credentials', () => { clearCredentials(); } );
|
ipcMain.handle( 'auth:clear-credentials', () => { clearCredentials(); } );
|
||||||
|
|
||||||
|
ipcMain.handle( 'local:open-folder', async () => {
|
||||||
|
const win = mainWindow ?? BrowserWindow.getFocusedWindow();
|
||||||
|
if ( !win ) return null;
|
||||||
|
const result = await dialog.showOpenDialog( win, { properties: [ 'openDirectory' ] } );
|
||||||
|
if ( result.canceled || result.filePaths.length === 0 ) return null;
|
||||||
|
const folderPath = result.filePaths[ 0 ];
|
||||||
|
addLocalRecent( folderPath );
|
||||||
|
return folderPath;
|
||||||
|
} );
|
||||||
|
|
||||||
|
ipcMain.handle( 'local:get-recents', () => loadLocalRecents() );
|
||||||
|
|
||||||
|
ipcMain.handle( 'local:remove-recent', ( _event, folderPath: string ) => {
|
||||||
|
return removeLocalRecent( folderPath );
|
||||||
|
} );
|
||||||
|
|
||||||
ipcMain.on( 'auth:login-success', () => {
|
ipcMain.on( 'auth:login-success', () => {
|
||||||
// Create the main window first, close login only after it exists.
|
// Create the main window first, close login only after it exists.
|
||||||
// Closing login before main is ready triggers window-all-closed → app quit.
|
// Closing login before main is ready triggers window-all-closed → app quit.
|
||||||
|
|
|
||||||
|
|
@ -12,3 +12,12 @@ contextBridge.exposeInMainWorld( 'electronAuth', {
|
||||||
clearCredentials: () =>
|
clearCredentials: () =>
|
||||||
ipcRenderer.invoke( 'auth:clear-credentials' ),
|
ipcRenderer.invoke( 'auth:clear-credentials' ),
|
||||||
} );
|
} );
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld( 'electronLocal', {
|
||||||
|
openFolder: () =>
|
||||||
|
ipcRenderer.invoke( 'local:open-folder' ),
|
||||||
|
getRecents: () =>
|
||||||
|
ipcRenderer.invoke( 'local:get-recents' ),
|
||||||
|
removeRecent: ( folderPath: string ) =>
|
||||||
|
ipcRenderer.invoke( 'local:remove-recent', folderPath ),
|
||||||
|
} );
|
||||||
|
|
|
||||||
|
|
@ -78,8 +78,12 @@ class EditorShell extends HTMLElement {
|
||||||
|
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const projectId = params.get('project') ?? '';
|
const projectId = params.get('project') ?? '';
|
||||||
|
const localRoot = params.get('localRoot') ?? '';
|
||||||
|
const remoteProject = params.get('remoteProject') ?? '';
|
||||||
const projectName = params.get('name') ?? 'Project';
|
const projectName = params.get('name') ?? 'Project';
|
||||||
Editor.get().projectId = projectId;
|
Editor.get().projectId = projectId;
|
||||||
|
Editor.get().localRoot = localRoot;
|
||||||
|
Editor.get().remoteProject = remoteProject;
|
||||||
Editor.get().projectName = projectName;
|
Editor.get().projectName = projectName;
|
||||||
|
|
||||||
this.innerHTML = `
|
this.innerHTML = `
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
file-tree-panel {
|
file-tree-panel
|
||||||
|
{
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
@ -7,7 +8,8 @@ file-tree-panel {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ftp-header {
|
ftp-header
|
||||||
|
{
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|
@ -16,7 +18,8 @@ file-tree-panel {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ftp-btn {
|
.ftp-btn
|
||||||
|
{
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid #2a2d3a;
|
border: 1px solid #2a2d3a;
|
||||||
|
|
@ -29,7 +32,8 @@ file-tree-panel {
|
||||||
|
|
||||||
.ftp-btn:hover { color: #aaa; background: #1a1d27; border-color: #444; }
|
.ftp-btn:hover { color: #aaa; background: #1a1d27; border-color: #444; }
|
||||||
|
|
||||||
.ftp-inline-create {
|
ftp-inline-create
|
||||||
|
{
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|
@ -38,13 +42,16 @@ file-tree-panel {
|
||||||
border-bottom: 1px solid #2a2d3a;
|
border-bottom: 1px solid #2a2d3a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ftp-inline-label {
|
ftp-inline-label
|
||||||
|
{
|
||||||
|
display: block;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #666;
|
color: #666;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ftp-rename-input {
|
.ftp-rename-input
|
||||||
|
{
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 4px 6px;
|
padding: 4px 6px;
|
||||||
background: #0f1117;
|
background: #0f1117;
|
||||||
|
|
@ -56,54 +63,107 @@ file-tree-panel {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ftp-inline-create .ftp-btn {
|
ftp-inline-create .ftp-btn
|
||||||
|
{
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ftp-tree {
|
ftp-tree
|
||||||
|
{
|
||||||
|
display: block;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ftp-list {
|
ftp-list
|
||||||
|
{
|
||||||
|
display: block;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ftp-list .ftp-list {
|
ftp-list ftp-list
|
||||||
|
{
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ftp-dir.open > .ftp-list {
|
ftp-dir
|
||||||
|
{
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ftp-dir-label {
|
ftp-dir.open > ftp-list
|
||||||
|
{
|
||||||
display: block;
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
ftp-dir-label
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
padding: 3px 8px 3px calc(8px + var(--depth, 0) * 12px);
|
padding: 3px 8px 3px calc(8px + var(--depth, 0) * 12px);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #7b7f96;
|
color: #7b7f96;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ftp-dir-label:hover { color: #c8cbde; }
|
ftp-dir-label::before
|
||||||
|
{
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 4px 0 4px 6px;
|
||||||
|
border-color: transparent transparent transparent currentColor;
|
||||||
|
transition: transform 0.14s;
|
||||||
|
}
|
||||||
|
|
||||||
.ftp-dir.open > .ftp-dir-label { color: #c8cbde; }
|
ftp-dir.open > ftp-dir-label::before
|
||||||
|
{
|
||||||
|
transform: rotate( 90deg );
|
||||||
|
}
|
||||||
|
|
||||||
.ftp-file {
|
ftp-dir-label:hover { color: #c8cbde; }
|
||||||
padding: 3px 8px 3px calc(20px + var(--depth, 0) * 12px);
|
|
||||||
|
ftp-dir.open > ftp-dir-label { color: #c8cbde; }
|
||||||
|
|
||||||
|
ftp-dir,
|
||||||
|
ftp-file
|
||||||
|
{
|
||||||
|
margin-top: 0.1em;
|
||||||
|
margin-bottom: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
ftp-file
|
||||||
|
{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 3px 8px 3px calc( 19px + var(--depth, 0) * 12px );
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #9ba4c7;
|
color: #9ba4c7;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ftp-file:hover { background: #1a1d27; color: #e2e4ed; }
|
.ftp-icon
|
||||||
.ftp-file.active { background: #1e2235; color: #7c8cff; }
|
{
|
||||||
.ftp-dir-label.active { background: #1e2235; color: #c8cbde; }
|
flex-shrink: 0;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.ftp-type-error {
|
ftp-file:hover { background: #1a1d27; color: #e2e4ed; }
|
||||||
|
ftp-file.active { background: #1e2235; color: #7c8cff; }
|
||||||
|
ftp-dir-label.active { background: #1e2235; color: #c8cbde; }
|
||||||
|
|
||||||
|
ftp-type-error
|
||||||
|
{
|
||||||
|
display: block;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
color: #e07070;
|
color: #e07070;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|
@ -111,14 +171,17 @@ file-tree-panel {
|
||||||
border-bottom: 1px solid #3a2a2a;
|
border-bottom: 1px solid #3a2a2a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ftp-empty {
|
ftp-empty
|
||||||
|
{
|
||||||
display: block;
|
display: block;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
color: #555;
|
color: #555;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ftp-up {
|
ftp-up
|
||||||
|
{
|
||||||
|
display: block;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: #666;
|
color: #666;
|
||||||
|
|
@ -127,4 +190,4 @@ file-tree-panel {
|
||||||
border-bottom: 1px solid #1e2030;
|
border-bottom: 1px solid #1e2030;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ftp-up:hover { color: #c8cbde; background: #1a1d27; }
|
ftp-up:hover { color: #c8cbde; background: #1a1d27; }
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,11 @@ class FileTreePanel extends HTMLElement {
|
||||||
|
|
||||||
this.className = 'file-tree-panel';
|
this.className = 'file-tree-panel';
|
||||||
this.innerHTML = `
|
this.innerHTML = `
|
||||||
<div class="ftp-header">
|
<ftp-header>
|
||||||
<button class="ftp-btn" data-action="add-file" title="Add file">+F</button>
|
<button class="ftp-btn" data-action="add-file" title="Add file">+F</button>
|
||||||
<button class="ftp-btn" data-action="add-dir" title="Add directory">+D</button>
|
<button class="ftp-btn" data-action="add-dir" title="Add directory">+D</button>
|
||||||
</div>
|
</ftp-header>
|
||||||
<div class="ftp-tree">Loading…</div>
|
<ftp-tree>Loading…</ftp-tree>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.querySelector( '[data-action="add-file"]' )!.addEventListener( 'click', () => this.addFile() );
|
this.querySelector( '[data-action="add-file"]' )!.addEventListener( 'click', () => this.addFile() );
|
||||||
|
|
@ -43,7 +43,7 @@ class FileTreePanel extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateTabLabel(): void {
|
_updateTabLabel(): void {
|
||||||
const label = '📁 ' + this._dirnameDisplay();
|
const label = this._dirnameDisplay();
|
||||||
this.dispatchEvent( new CustomEvent( 'panel:label-change', { bubbles: true, detail: { label } } ) );
|
this.dispatchEvent( new CustomEvent( 'panel:label-change', { bubbles: true, detail: { label } } ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -73,9 +73,14 @@ class FileTreePanel extends HTMLElement {
|
||||||
|
|
||||||
async refresh(): Promise<void> {
|
async refresh(): Promise<void> {
|
||||||
const state = Editor.get();
|
const state = Editor.get();
|
||||||
const res = await fetch( `/api/files/${state.projectId}/tree` );
|
const treeUrl = state.localRoot
|
||||||
|
? `/api/local/tree?root=${ encodeURIComponent( state.localRoot ) }`
|
||||||
|
: state.remoteProject
|
||||||
|
? `/api/remote/files/${ state.remoteProject }/tree`
|
||||||
|
: `/api/files/${ state.projectId }/tree`;
|
||||||
|
const res = await fetch( treeUrl );
|
||||||
const allNodes = await res.json() as FileNode[];
|
const allNodes = await res.json() as FileNode[];
|
||||||
const tree = this.querySelector( '.ftp-tree' )!;
|
const tree = this.querySelector( 'ftp-tree' )!;
|
||||||
|
|
||||||
let nodes: FileNode[];
|
let nodes: FileNode[];
|
||||||
|
|
||||||
|
|
@ -90,10 +95,10 @@ class FileTreePanel extends HTMLElement {
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
if ( this._rootPath ) {
|
if ( this._rootPath ) {
|
||||||
html += `<div class="ftp-up" data-action="go-up">[ .. ]</div>`;
|
html += `<ftp-up data-action="go-up">[ .. ]</ftp-up>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
html += nodes.length ? this.renderNodes( nodes ) : '<span class="ftp-empty">Empty</span>';
|
html += nodes.length ? this.renderNodes( nodes ) : '<ftp-empty>Empty</ftp-empty>';
|
||||||
tree.innerHTML = html;
|
tree.innerHTML = html;
|
||||||
|
|
||||||
const upBtn = tree.querySelector( '[data-action="go-up"]' );
|
const upBtn = tree.querySelector( '[data-action="go-up"]' );
|
||||||
|
|
@ -107,11 +112,11 @@ class FileTreePanel extends HTMLElement {
|
||||||
|
|
||||||
bindTree( tree: Element ): void {
|
bindTree( tree: Element ): void {
|
||||||
const state = Editor.get();
|
const state = Editor.get();
|
||||||
tree.querySelectorAll( '.ftp-file' ).forEach( el => {
|
tree.querySelectorAll( 'ftp-file' ).forEach( el => {
|
||||||
el.addEventListener( 'click', async () => {
|
el.addEventListener( 'click', async () => {
|
||||||
const path = ( el as HTMLElement ).dataset.path!;
|
const path = ( el as HTMLElement ).dataset.path!;
|
||||||
this.selectedPath = path;
|
this.selectedPath = path;
|
||||||
tree.querySelectorAll( '.ftp-file, .ftp-dir-label' ).forEach( f => f.classList.remove( 'active' ) );
|
tree.querySelectorAll( 'ftp-file, ftp-dir-label' ).forEach( f => f.classList.remove( 'active' ) );
|
||||||
el.classList.add( 'active' );
|
el.classList.add( 'active' );
|
||||||
|
|
||||||
await state.fileEditorRegistry.load( state.projectId );
|
await state.fileEditorRegistry.load( state.projectId );
|
||||||
|
|
@ -190,13 +195,13 @@ class FileTreePanel extends HTMLElement {
|
||||||
this.showItemMenu( ( el as HTMLElement ).dataset.path!, me.clientX, me.clientY );
|
this.showItemMenu( ( el as HTMLElement ).dataset.path!, me.clientX, me.clientY );
|
||||||
} );
|
} );
|
||||||
} );
|
} );
|
||||||
tree.querySelectorAll( '.ftp-dir-label' ).forEach( el => {
|
tree.querySelectorAll( 'ftp-dir-label' ).forEach( el => {
|
||||||
el.addEventListener( 'click', () => {
|
el.addEventListener( 'click', () => {
|
||||||
const li = el.closest( 'li' )!;
|
const dir = el.closest( 'ftp-dir' )!;
|
||||||
li.classList.toggle( 'open' );
|
dir.classList.toggle( 'open' );
|
||||||
const path = ( el as HTMLElement ).dataset.path!;
|
const path = ( el as HTMLElement ).dataset.path!;
|
||||||
this.selectedPath = path;
|
this.selectedPath = path;
|
||||||
tree.querySelectorAll( '.ftp-file, .ftp-dir-label' ).forEach( f => f.classList.remove( 'active' ) );
|
tree.querySelectorAll( 'ftp-file, ftp-dir-label' ).forEach( f => f.classList.remove( 'active' ) );
|
||||||
el.classList.add( 'active' );
|
el.classList.add( 'active' );
|
||||||
} );
|
} );
|
||||||
el.addEventListener( 'dblclick', () => {
|
el.addEventListener( 'dblclick', () => {
|
||||||
|
|
@ -216,8 +221,8 @@ class FileTreePanel extends HTMLElement {
|
||||||
showItemMenu( targetPath: string, x: number, y: number ): void
|
showItemMenu( targetPath: string, x: number, y: number ): void
|
||||||
{
|
{
|
||||||
this.selectedPath = targetPath;
|
this.selectedPath = targetPath;
|
||||||
const tree = this.querySelector( '.ftp-tree' )!;
|
const tree = this.querySelector( 'ftp-tree' )!;
|
||||||
tree.querySelectorAll( '.ftp-file, .ftp-dir-label' ).forEach( f => f.classList.remove( 'active' ) );
|
tree.querySelectorAll( 'ftp-file, ftp-dir-label' ).forEach( f => f.classList.remove( 'active' ) );
|
||||||
tree.querySelector( `[data-path="${CSS.escape( targetPath )}"]` )?.classList.add( 'active' );
|
tree.querySelector( `[data-path="${CSS.escape( targetPath )}"]` )?.classList.add( 'active' );
|
||||||
|
|
||||||
const menu = new ContextMenuDirectory( null );
|
const menu = new ContextMenuDirectory( null );
|
||||||
|
|
@ -230,19 +235,18 @@ class FileTreePanel extends HTMLElement {
|
||||||
|
|
||||||
startInlineRename( oldPath: string ): void
|
startInlineRename( oldPath: string ): void
|
||||||
{
|
{
|
||||||
const tree = this.querySelector( '.ftp-tree' )!;
|
const tree = this.querySelector( 'ftp-tree' )!;
|
||||||
const lastSlash = oldPath.lastIndexOf( '/' );
|
const lastSlash = oldPath.lastIndexOf( '/' );
|
||||||
const currentName = lastSlash === -1 ? oldPath : oldPath.slice( lastSlash + 1 );
|
const currentName = lastSlash === -1 ? oldPath : oldPath.slice( lastSlash + 1 );
|
||||||
|
|
||||||
const overlay = document.createElement( 'div' );
|
const overlay = document.createElement( 'ftp-inline-create' );
|
||||||
overlay.className = 'ftp-inline-create';
|
|
||||||
|
|
||||||
const input = document.createElement( 'input' );
|
const input = document.createElement( 'input' );
|
||||||
input.className = 'ftp-rename-input';
|
input.className = 'ftp-rename-input';
|
||||||
input.value = currentName;
|
input.value = currentName;
|
||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
|
|
||||||
overlay.innerHTML = `<span class="ftp-inline-label">✎ Rename: ${oldPath}</span>`;
|
overlay.innerHTML = `<ftp-inline-label>✎ Rename: ${oldPath}</ftp-inline-label>`;
|
||||||
overlay.appendChild( input );
|
overlay.appendChild( input );
|
||||||
|
|
||||||
const btnRename = document.createElement( 'button' );
|
const btnRename = document.createElement( 'button' );
|
||||||
|
|
@ -280,10 +284,18 @@ class FileTreePanel extends HTMLElement {
|
||||||
async renameEntry( oldPath: string, newName: string ): Promise<void>
|
async renameEntry( oldPath: string, newName: string ): Promise<void>
|
||||||
{
|
{
|
||||||
const state = Editor.get();
|
const state = Editor.get();
|
||||||
const res = await fetch( `/api/files/${state.projectId}/rename`, {
|
const body = state.localRoot
|
||||||
|
? { root: state.localRoot, path: oldPath, newName }
|
||||||
|
: { path: oldPath, newName };
|
||||||
|
const url = state.localRoot
|
||||||
|
? `/api/local/rename`
|
||||||
|
: state.remoteProject
|
||||||
|
? `/api/remote/files/${ state.remoteProject }/rename`
|
||||||
|
: `/api/files/${ state.projectId }/rename`;
|
||||||
|
const res = await fetch( url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify( { path: oldPath, newName } ),
|
body: JSON.stringify( body ),
|
||||||
} );
|
} );
|
||||||
|
|
||||||
if ( !res.ok ) return;
|
if ( !res.ok ) return;
|
||||||
|
|
@ -310,10 +322,18 @@ class FileTreePanel extends HTMLElement {
|
||||||
if ( !confirmed ) return;
|
if ( !confirmed ) return;
|
||||||
|
|
||||||
const state = Editor.get();
|
const state = Editor.get();
|
||||||
const res = await fetch( `/api/files/${state.projectId}/delete`, {
|
const body = state.localRoot
|
||||||
|
? { root: state.localRoot, path: targetPath }
|
||||||
|
: { path: targetPath };
|
||||||
|
const url = state.localRoot
|
||||||
|
? `/api/local/delete`
|
||||||
|
: state.remoteProject
|
||||||
|
? `/api/remote/files/${ state.remoteProject }/delete`
|
||||||
|
: `/api/files/${ state.projectId }/delete`;
|
||||||
|
const res = await fetch( url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify( { path: targetPath } ),
|
body: JSON.stringify( body ),
|
||||||
} );
|
} );
|
||||||
|
|
||||||
if ( !res.ok ) return;
|
if ( !res.ok ) return;
|
||||||
|
|
@ -325,8 +345,8 @@ class FileTreePanel extends HTMLElement {
|
||||||
resolveTargetDir(): string {
|
resolveTargetDir(): string {
|
||||||
if ( !this.selectedPath ) return this._rootPath;
|
if ( !this.selectedPath ) return this._rootPath;
|
||||||
|
|
||||||
const tree = this.querySelector( '.ftp-tree' )!;
|
const tree = this.querySelector( 'ftp-tree' )!;
|
||||||
const dirLabel = tree.querySelector( `.ftp-dir-label[data-path="${CSS.escape( this.selectedPath )}"]` );
|
const dirLabel = tree.querySelector( `ftp-dir-label[data-path="${CSS.escape( this.selectedPath )}"]` );
|
||||||
if ( dirLabel ) return this.selectedPath;
|
if ( dirLabel ) return this.selectedPath;
|
||||||
|
|
||||||
const lastSlash = this.selectedPath.lastIndexOf( '/' );
|
const lastSlash = this.selectedPath.lastIndexOf( '/' );
|
||||||
|
|
@ -351,7 +371,13 @@ class FileTreePanel extends HTMLElement {
|
||||||
|
|
||||||
async findFreeName(projectId: string, dir: string, type: 'file' | 'directory', ext: string): Promise<string> {
|
async findFreeName(projectId: string, dir: string, type: 'file' | 'directory', ext: string): Promise<string> {
|
||||||
const baseName = type === 'file' ? 'file' : 'directory';
|
const baseName = type === 'file' ? 'file' : 'directory';
|
||||||
const res = await fetch(`/api/files/${projectId}/tree`);
|
const state = Editor.get();
|
||||||
|
const treeUrl = state.localRoot
|
||||||
|
? `/api/local/tree?root=${ encodeURIComponent( state.localRoot ) }`
|
||||||
|
: state.remoteProject
|
||||||
|
? `/api/remote/files/${ state.remoteProject }/tree`
|
||||||
|
: `/api/files/${ projectId }/tree`;
|
||||||
|
const res = await fetch( treeUrl );
|
||||||
const tree = await res.json() as FileNode[];
|
const tree = await res.json() as FileNode[];
|
||||||
for (let i = 1; i <= 999; i++) {
|
for (let i = 1; i <= 999; i++) {
|
||||||
const name = ext ? `${baseName}${i > 1 ? i : ''}.${ext}` : `${baseName}${i > 1 ? i : ''}`;
|
const name = ext ? `${baseName}${i > 1 ? i : ''}.${ext}` : `${baseName}${i > 1 ? i : ''}`;
|
||||||
|
|
@ -370,16 +396,15 @@ class FileTreePanel extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
startInlineCreate(fullPath: string, type: 'file' | 'directory', defaultName: string): void {
|
startInlineCreate(fullPath: string, type: 'file' | 'directory', defaultName: string): void {
|
||||||
const tree = this.querySelector('.ftp-tree')!;
|
const tree = this.querySelector('ftp-tree')!;
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('ftp-inline-create');
|
||||||
overlay.className = 'ftp-inline-create';
|
|
||||||
|
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.className = 'ftp-rename-input';
|
input.className = 'ftp-rename-input';
|
||||||
input.value = defaultName;
|
input.value = defaultName;
|
||||||
input.type = 'text';
|
input.type = 'text';
|
||||||
|
|
||||||
overlay.innerHTML = `<span class="ftp-inline-label">${type === 'file' ? '📄' : '📁'} New ${type} in: ${fullPath.includes('/') ? fullPath.slice(0, fullPath.lastIndexOf('/')) || '/' : '/'}</span>`;
|
overlay.innerHTML = `<ftp-inline-label>${type === 'file' ? '📄' : '📁'} New ${type} in: ${fullPath.includes('/') ? fullPath.slice(0, fullPath.lastIndexOf('/')) || '/' : '/'}</ftp-inline-label>`;
|
||||||
overlay.appendChild(input);
|
overlay.appendChild(input);
|
||||||
|
|
||||||
const btnCreate = document.createElement('button');
|
const btnCreate = document.createElement('button');
|
||||||
|
|
@ -417,10 +442,18 @@ class FileTreePanel extends HTMLElement {
|
||||||
async createEntry(type: 'file' | 'directory', path: string): Promise<void> {
|
async createEntry(type: 'file' | 'directory', path: string): Promise<void> {
|
||||||
const state = Editor.get();
|
const state = Editor.get();
|
||||||
const endpoint = type === 'file' ? 'create-file' : 'create-directory';
|
const endpoint = type === 'file' ? 'create-file' : 'create-directory';
|
||||||
const res = await fetch(`/api/files/${state.projectId}/${endpoint}`, {
|
const body = state.localRoot
|
||||||
|
? { root: state.localRoot, path }
|
||||||
|
: { path };
|
||||||
|
const url = state.localRoot
|
||||||
|
? `/api/local/${ endpoint }`
|
||||||
|
: state.remoteProject
|
||||||
|
? `/api/remote/files/${ state.remoteProject }/${ endpoint }`
|
||||||
|
: `/api/files/${ state.projectId }/${ endpoint }`;
|
||||||
|
const res = await fetch( url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ path }),
|
body: JSON.stringify( body ),
|
||||||
} );
|
} );
|
||||||
if ( res.ok )
|
if ( res.ok )
|
||||||
{
|
{
|
||||||
|
|
@ -434,15 +467,18 @@ class FileTreePanel extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
renderNodes(nodes: FileNode[], depth = 0): string {
|
renderNodes(nodes: FileNode[], depth = 0): string {
|
||||||
return `<ul class="ftp-list" style="--depth:${depth}">` + nodes.map(n => {
|
const dirIcon = `<img class="ftp-icon" src="/icons/directory.svg" alt="">`;
|
||||||
|
const fileIcon = `<img class="ftp-icon" src="/icons/file.svg" alt="">`;
|
||||||
|
const sorted = [ ...nodes ].sort( ( a, b ) => a.type === b.type ? 0 : a.type === 'directory' ? -1 : 1 );
|
||||||
|
return `<ftp-list style="--depth:${depth}">` + sorted.map(n => {
|
||||||
if (n.type === 'directory') {
|
if (n.type === 'directory') {
|
||||||
return `<li class="ftp-dir open">
|
return `<ftp-dir>
|
||||||
<span class="ftp-dir-label" data-path="${n.path}">▸ ${n.name}</span>
|
<ftp-dir-label data-path="${n.path}">${dirIcon}${n.name}</ftp-dir-label>
|
||||||
${this.renderNodes(n.children ?? [], depth + 1)}
|
${this.renderNodes(n.children ?? [], depth + 1)}
|
||||||
</li>`;
|
</ftp-dir>`;
|
||||||
}
|
}
|
||||||
return `<li class="ftp-file" data-path="${n.path}">${n.name}</li>`;
|
return `<ftp-file data-path="${n.path}">${fileIcon}${n.name}</ftp-file>`;
|
||||||
}).join('') + '</ul>';
|
}).join('') + '</ftp-list>';
|
||||||
}
|
}
|
||||||
|
|
||||||
_showTypeError( filePath: string ): void
|
_showTypeError( filePath: string ): void
|
||||||
|
|
@ -451,16 +487,15 @@ class FileTreePanel extends HTMLElement {
|
||||||
const ext = lastDot === -1 ? '' : filePath.slice( lastDot );
|
const ext = lastDot === -1 ? '' : filePath.slice( lastDot );
|
||||||
const msg = ext ? `Can't open extension "${ext}"` : `Can't open file without extension`;
|
const msg = ext ? `Can't open extension "${ext}"` : `Can't open file without extension`;
|
||||||
|
|
||||||
const tree = this.querySelector( '.ftp-tree' )!;
|
const tree = this.querySelector( 'ftp-tree' )!;
|
||||||
const existing = tree.querySelector( '.ftp-type-error' );
|
const existing = tree.querySelector( 'ftp-type-error' );
|
||||||
if ( existing ) existing.remove();
|
if ( existing ) existing.remove();
|
||||||
|
|
||||||
const div = document.createElement( 'div' );
|
const el = document.createElement( 'ftp-type-error' );
|
||||||
div.className = 'ftp-type-error';
|
el.textContent = msg;
|
||||||
div.textContent = msg;
|
tree.prepend( el );
|
||||||
tree.prepend( div );
|
|
||||||
|
|
||||||
setTimeout( () => div.remove(), 3000 );
|
setTimeout( () => el.remove(), 3000 );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -442,6 +442,85 @@ project-list-default {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Electron tabs ───────────────────────── */
|
||||||
|
|
||||||
|
pld-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.2rem;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX( -50% );
|
||||||
|
}
|
||||||
|
|
||||||
|
pld-tab {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.4rem 1.3rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-family: 'Barlow', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: italic;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: rgba( 255, 255, 255, 0.35 );
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
pld-tab.active {
|
||||||
|
color: #fff;
|
||||||
|
background: rgba( 255, 255, 255, 0.09 );
|
||||||
|
border-color: rgba( 255, 255, 255, 0.15 );
|
||||||
|
}
|
||||||
|
|
||||||
|
pld-tab:hover:not(.active) {
|
||||||
|
color: rgba( 255, 255, 255, 0.65 );
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Local folder list ───────────────────── */
|
||||||
|
|
||||||
|
.pld-no-recents {
|
||||||
|
color: rgba( 255, 255, 255, 0.28 );
|
||||||
|
font-size: 0.9rem;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pld-btn-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: -7px;
|
||||||
|
left: -7px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale( 0.6 );
|
||||||
|
transition: opacity 0.14s, transform 0.14s;
|
||||||
|
z-index: 5;
|
||||||
|
background: #b02050;
|
||||||
|
box-shadow: 0 2px 8px rgba( 0, 0, 0, 0.55 );
|
||||||
|
}
|
||||||
|
|
||||||
|
.pld-recent-row:hover .pld-btn-remove {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale( 1 );
|
||||||
|
}
|
||||||
|
|
||||||
|
.pld-btn-remove:hover { background: #e93978; }
|
||||||
|
|
||||||
|
.pld-btn-remove svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Mobile ──────────────────────────────── */
|
/* ── Mobile ──────────────────────────────── */
|
||||||
|
|
||||||
@media ( max-width: 600px ) {
|
@media ( max-width: 600px ) {
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,35 @@
|
||||||
import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js';
|
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 AUTH_HOST = 'https://account.rokojori.com';
|
||||||
const APP_URL = 'https://roject.rokojori.com';
|
const APP_URL = 'https://roject.rokojori.com';
|
||||||
|
|
||||||
interface JwtUser {
|
interface JwtUser
|
||||||
|
{
|
||||||
userId: string;
|
userId: string;
|
||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Project {
|
interface Project
|
||||||
|
{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
owner_id: string;
|
owner_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProjectMember {
|
interface ProjectMember
|
||||||
|
{
|
||||||
id: string;
|
id: string;
|
||||||
project_id: string;
|
project_id: string;
|
||||||
member_type: 'user' | 'group';
|
member_type: 'user' | 'group';
|
||||||
|
|
@ -22,22 +37,197 @@ interface ProjectMember {
|
||||||
role: string;
|
role: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hueFromId( id: string ): number {
|
function hueFromId( id: string ): number
|
||||||
|
{
|
||||||
let h = 0;
|
let h = 0;
|
||||||
for ( let i = 0; i < id.length; i++ ) {
|
for ( let i = 0; i < id.length; i++ )
|
||||||
|
{
|
||||||
h = ( h * 31 + id.charCodeAt( i ) ) % 360;
|
h = ( h * 31 + id.charCodeAt( i ) ) % 360;
|
||||||
}
|
}
|
||||||
return Math.abs( h );
|
return Math.abs( h );
|
||||||
}
|
}
|
||||||
|
|
||||||
class ProjectListDefault extends HTMLElement {
|
function escapeAttr( s: string ): string
|
||||||
user: JwtUser | null = null;
|
{
|
||||||
|
return s.replace( /&/g, '&' ).replace( /"/g, '"' );
|
||||||
|
}
|
||||||
|
|
||||||
async connectedCallback(): Promise<void> {
|
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();
|
await this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async render(): Promise<void> {
|
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 res = await fetch( '/api/projects' );
|
||||||
const projects = res.ok ? await res.json() as Project[] : [];
|
const projects = res.ok ? await res.json() as Project[] : [];
|
||||||
const logoutHref = `${ AUTH_HOST }/api/auth/logout?redirect=${ encodeURIComponent( APP_URL ) }`;
|
const logoutHref = `${ AUTH_HOST }/api/auth/logout?redirect=${ encodeURIComponent( APP_URL ) }`;
|
||||||
|
|
@ -70,14 +260,17 @@ class ProjectListDefault extends HTMLElement {
|
||||||
this.bindEvents( projects );
|
this.bindEvents( projects );
|
||||||
}
|
}
|
||||||
|
|
||||||
private rowHtml( p: Project, i: number ): string {
|
// ── Shared helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private rowHtml( p: Project, i: number ): string
|
||||||
|
{
|
||||||
const color = `hsl( ${ hueFromId( p.id ) }, 95%, 45% )`;
|
const color = `hsl( ${ hueFromId( p.id ) }, 95%, 45% )`;
|
||||||
return `
|
return `
|
||||||
<div class="pld-row" data-id="${ p.id }" data-name="${ p.name }" data-owner="${ p.owner_id }"
|
<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">
|
style="--project-color: ${ color }; animation-delay: ${ 0.04 + i * 0.06 }s">
|
||||||
<div class="pld-badge-wrap">
|
<div class="pld-badge-wrap">
|
||||||
<div class="pld-badge" style="animation-delay: ${ i * 0.45 }s"></div>
|
<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">
|
<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">
|
<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"/>
|
<line x1="5" y1="5" x2="15" y2="15"/><line x1="15" y1="5" x2="5" y2="15"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -94,19 +287,25 @@ class ProjectListDefault extends HTMLElement {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bindEvents( projects: Project[] ): void {
|
private bindEvents( projects: Project[] ): void
|
||||||
|
{
|
||||||
this.querySelector( '#pld-new-row' )!.addEventListener( 'click', () => this.openCreateDialog() );
|
this.querySelector( '#pld-new-row' )!.addEventListener( 'click', () => this.openCreateDialog() );
|
||||||
|
|
||||||
this.querySelectorAll( '.pld-row:not(.new-project)' ).forEach( row => {
|
this.querySelectorAll( '.pld-row:not(.new-project)' ).forEach( row =>
|
||||||
row.addEventListener( 'click', ( e: Event ) => {
|
{
|
||||||
|
row.addEventListener( 'click', ( e: Event ) =>
|
||||||
|
{
|
||||||
if ( ( e.target as HTMLElement ).closest( '.pld-btn-delete, .pld-btn-members' ) ) return;
|
if ( ( e.target as HTMLElement ).closest( '.pld-btn-delete, .pld-btn-members' ) ) return;
|
||||||
const el = row as HTMLElement;
|
const el = row as HTMLElement;
|
||||||
location.href = `/editor.html?project=${ el.dataset.id }&name=${ encodeURIComponent( el.dataset.name! ) }`;
|
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 => {
|
this.querySelectorAll( '.pld-btn-delete' ).forEach( btn =>
|
||||||
btn.addEventListener( 'click', async ( e: Event ) => {
|
{
|
||||||
|
btn.addEventListener( 'click', async ( e: Event ) =>
|
||||||
|
{
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const el = btn as HTMLElement;
|
const el = btn as HTMLElement;
|
||||||
const ok = await showConfirmDialog( {
|
const ok = await showConfirmDialog( {
|
||||||
|
|
@ -118,13 +317,22 @@ class ProjectListDefault extends HTMLElement {
|
||||||
danger: true
|
danger: true
|
||||||
} );
|
} );
|
||||||
if ( !ok ) return;
|
if ( !ok ) return;
|
||||||
await fetch( `/api/projects/${ el.dataset.id }`, { method: 'DELETE' } );
|
await fetch( this._projectUrl( `/${ el.dataset.id }` ), { method: 'DELETE' } );
|
||||||
await this.render();
|
if ( window.electronLocal )
|
||||||
|
{
|
||||||
|
await this._renderOnlineContent( this.querySelector( '#pld-content' ) as HTMLElement );
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await this._renderFull();
|
||||||
|
}
|
||||||
} );
|
} );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
this.querySelectorAll( '.pld-btn-members' ).forEach( btn => {
|
this.querySelectorAll( '.pld-btn-members' ).forEach( btn =>
|
||||||
btn.addEventListener( 'click', ( e: Event ) => {
|
{
|
||||||
|
btn.addEventListener( 'click', ( e: Event ) =>
|
||||||
|
{
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const id = ( btn as HTMLElement ).dataset.id!;
|
const id = ( btn as HTMLElement ).dataset.id!;
|
||||||
const row = this.querySelector( `.pld-row[data-id="${ id }"]` ) as HTMLElement;
|
const row = this.querySelector( `.pld-row[data-id="${ id }"]` ) as HTMLElement;
|
||||||
|
|
@ -133,7 +341,8 @@ class ProjectListDefault extends HTMLElement {
|
||||||
} );
|
} );
|
||||||
}
|
}
|
||||||
|
|
||||||
private openCreateDialog(): void {
|
private openCreateDialog(): void
|
||||||
|
{
|
||||||
const overlay = this.querySelector( '.pld-create-overlay' ) as HTMLElement;
|
const overlay = this.querySelector( '.pld-create-overlay' ) as HTMLElement;
|
||||||
overlay.style.display = 'flex';
|
overlay.style.display = 'flex';
|
||||||
overlay.innerHTML = `
|
overlay.innerHTML = `
|
||||||
|
|
@ -157,25 +366,38 @@ class ProjectListDefault extends HTMLElement {
|
||||||
const input = overlay.querySelector( '.pld-cp-input' ) as HTMLInputElement;
|
const input = overlay.querySelector( '.pld-cp-input' ) as HTMLInputElement;
|
||||||
setTimeout( () => input.focus(), 30 );
|
setTimeout( () => input.focus(), 30 );
|
||||||
|
|
||||||
overlay.querySelector( '.pld-cp-cancel' )!.addEventListener( 'click', () => {
|
overlay.querySelector( '.pld-cp-cancel' )!.addEventListener( 'click', () =>
|
||||||
|
{
|
||||||
overlay.style.display = 'none';
|
overlay.style.display = 'none';
|
||||||
} );
|
} );
|
||||||
|
|
||||||
overlay.querySelector( '.pld-cp-form' )!.addEventListener( 'submit', async ( e: Event ) => {
|
overlay.querySelector( '.pld-cp-form' )!.addEventListener( 'submit', async ( e: Event ) =>
|
||||||
|
{
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const name = input.value.trim();
|
const name = input.value.trim();
|
||||||
if ( !name ) return;
|
if ( !name ) return;
|
||||||
overlay.style.display = 'none';
|
overlay.style.display = 'none';
|
||||||
const res = await fetch( '/api/projects', {
|
const res = await fetch( this._projectUrl( '' ), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify( { name } )
|
body: JSON.stringify( { name } )
|
||||||
} );
|
} );
|
||||||
if ( res.ok ) await this.render();
|
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> {
|
private async openMembersPanel( projectId: string, row: HTMLElement ): Promise<void>
|
||||||
|
{
|
||||||
const overlay = this.querySelector( '.pld-members-overlay' ) as HTMLElement;
|
const overlay = this.querySelector( '.pld-members-overlay' ) as HTMLElement;
|
||||||
overlay.style.display = 'flex';
|
overlay.style.display = 'flex';
|
||||||
overlay.innerHTML = `<div class="pld-mp"><p class="pld-mp-loading">Loading…</p></div>`;
|
overlay.innerHTML = `<div class="pld-mp"><p class="pld-mp-loading">Loading…</p></div>`;
|
||||||
|
|
@ -184,7 +406,7 @@ class ProjectListDefault extends HTMLElement {
|
||||||
panel.addEventListener( 'click', e => e.stopPropagation() );
|
panel.addEventListener( 'click', e => e.stopPropagation() );
|
||||||
overlay.addEventListener( 'click', () => { overlay.style.display = 'none'; }, { once: true } );
|
overlay.addEventListener( 'click', () => { overlay.style.display = 'none'; }, { once: true } );
|
||||||
|
|
||||||
const res = await fetch( `/api/projects/${ projectId }/members` );
|
const res = await fetch( this._projectUrl( `/${ projectId }/members` ) );
|
||||||
const members = res.ok ? await res.json() as ProjectMember[] : [];
|
const members = res.ok ? await res.json() as ProjectMember[] : [];
|
||||||
|
|
||||||
const ownerId = row.dataset.owner ?? '';
|
const ownerId = row.dataset.owner ?? '';
|
||||||
|
|
@ -223,19 +445,22 @@ class ProjectListDefault extends HTMLElement {
|
||||||
|
|
||||||
panel.querySelector( '.pld-mp-close' )!.addEventListener( 'click', () => { overlay.style.display = 'none'; } );
|
panel.querySelector( '.pld-mp-close' )!.addEventListener( 'click', () => { overlay.style.display = 'none'; } );
|
||||||
|
|
||||||
panel.querySelectorAll( '.pld-mp-remove' ).forEach( btn => {
|
panel.querySelectorAll( '.pld-mp-remove' ).forEach( btn =>
|
||||||
btn.addEventListener( 'click', async () => {
|
{
|
||||||
|
btn.addEventListener( 'click', async () =>
|
||||||
|
{
|
||||||
const el = btn as HTMLElement;
|
const el = btn as HTMLElement;
|
||||||
await fetch( `/api/projects/${ el.dataset.pid }/members/${ el.dataset.mid }`, { method: 'DELETE' } );
|
await fetch( this._projectUrl( `/${ el.dataset.pid }/members/${ el.dataset.mid }` ), { method: 'DELETE' } );
|
||||||
await this.openMembersPanel( projectId, row );
|
await this.openMembersPanel( projectId, row );
|
||||||
} );
|
} );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
const addForm = panel.querySelector( '.pld-mp-add-form' ) as HTMLFormElement | null;
|
const addForm = panel.querySelector( '.pld-mp-add-form' ) as HTMLFormElement | null;
|
||||||
addForm?.addEventListener( 'submit', async ( e: Event ) => {
|
addForm?.addEventListener( 'submit', async ( e: Event ) =>
|
||||||
|
{
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const data = Object.fromEntries( new FormData( e.target as HTMLFormElement ) );
|
const data = Object.fromEntries( new FormData( e.target as HTMLFormElement ) );
|
||||||
await fetch( `/api/projects/${ projectId }/members`, {
|
await fetch( this._projectUrl( `/${ projectId }/members` ), {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify( data )
|
body: JSON.stringify( data )
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,13 @@ tab-container.tc-drop-target {
|
||||||
|
|
||||||
.dirty-dot { color: #f59e0b; font-size: 0.6rem; }
|
.dirty-dot { color: #f59e0b; font-size: 0.6rem; }
|
||||||
|
|
||||||
|
.tc-icon {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
.tc-menu {
|
.tc-menu {
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|
|
||||||
|
|
@ -207,14 +207,21 @@ class TabContainer extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
renderBar(): void {
|
renderBar(): void {
|
||||||
|
const iconMap: Record<string, string> = {
|
||||||
|
'file-tree': '/icons/directory.svg',
|
||||||
|
'code-panel': '/icons/file.svg',
|
||||||
|
'page-editor': '/icons/file.svg',
|
||||||
|
};
|
||||||
const bar = this.querySelector('.tc-tabs')!;
|
const bar = this.querySelector('.tc-tabs')!;
|
||||||
bar.innerHTML = this.tabs.map(t => {
|
bar.innerHTML = this.tabs.map(t => {
|
||||||
const unsaved = implementsInterface( t.element, FileEditorPanelDefinition ) &&
|
const unsaved = implementsInterface( t.element, FileEditorPanelDefinition ) &&
|
||||||
( t.element as FileEditorPanel ).hasUnsavedChanges();
|
( t.element as FileEditorPanel ).hasUnsavedChanges();
|
||||||
|
const iconSrc = iconMap[ t.panelType ];
|
||||||
|
const icon = iconSrc ? `<img class="tc-icon" src="${ iconSrc }" alt="">` : '';
|
||||||
return `
|
return `
|
||||||
<div class="tc-tab${t.id === this.activeId ? ' active' : ''}" draggable="true"
|
<div class="tc-tab${t.id === this.activeId ? ' active' : ''}" draggable="true"
|
||||||
data-tab-id="${t.id}" data-source="${this.id}">
|
data-tab-id="${t.id}" data-source="${this.id}">
|
||||||
${unsaved ? '<span class="dirty-dot">●</span>' : ''}${t.label}
|
${ icon }${unsaved ? '<span class="dirty-dot">●</span>' : ''}${t.label}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ export class Editor
|
||||||
|
|
||||||
projectId: string = '';
|
projectId: string = '';
|
||||||
projectName: string = '';
|
projectName: string = '';
|
||||||
|
localRoot: string = '';
|
||||||
|
remoteProject: string = '';
|
||||||
openDocs: Map<string, { content: string; dirty: boolean }> = new Map();
|
openDocs: Map<string, { content: string; dirty: boolean }> = new Map();
|
||||||
activeDoc: string | null = null;
|
activeDoc: string | null = null;
|
||||||
fileEditorRegistry: FileEditorRegistry = new FileEditorRegistry();
|
fileEditorRegistry: FileEditorRegistry = new FileEditorRegistry();
|
||||||
|
|
@ -55,7 +57,7 @@ export class Editor
|
||||||
|
|
||||||
if ( ! this.openDocs.has( filePath ) )
|
if ( ! this.openDocs.has( filePath ) )
|
||||||
{
|
{
|
||||||
const res = await fetch( `/api/files/${this.projectId}/${filePath}` );
|
const res = await fetch( this._readUrl( filePath ) );
|
||||||
const content = await res.text();
|
const content = await res.text();
|
||||||
this.openDocs.set( filePath, { content, dirty: false } );
|
this.openDocs.set( filePath, { content, dirty: false } );
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +94,7 @@ export class Editor
|
||||||
|
|
||||||
if ( !this.openDocs.has( filePath ) )
|
if ( !this.openDocs.has( filePath ) )
|
||||||
{
|
{
|
||||||
const res = await fetch( `/api/files/${this.projectId}/${filePath}` );
|
const res = await fetch( this._readUrl( filePath ) );
|
||||||
const content = await res.text();
|
const content = await res.text();
|
||||||
this.openDocs.set( filePath, { content, dirty: false } );
|
this.openDocs.set( filePath, { content, dirty: false } );
|
||||||
}
|
}
|
||||||
|
|
@ -112,7 +114,7 @@ export class Editor
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetch(
|
await fetch(
|
||||||
`/api/files/${this.projectId}/${filePath}`,
|
this._writeUrl( filePath ),
|
||||||
{
|
{
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'text/plain' },
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
|
@ -123,4 +125,22 @@ export class Editor
|
||||||
doc.dirty = false;
|
doc.dirty = false;
|
||||||
this.onDocumentSaved.dispatch( { path: filePath } );
|
this.onDocumentSaved.dispatch( { path: filePath } );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _readUrl( filePath: string ): string
|
||||||
|
{
|
||||||
|
if ( this.localRoot )
|
||||||
|
return `/api/local/read?root=${ encodeURIComponent( this.localRoot ) }&path=${ encodeURIComponent( filePath ) }`;
|
||||||
|
if ( this.remoteProject )
|
||||||
|
return `/api/remote/files/${ this.remoteProject }/${ filePath }`;
|
||||||
|
return `/api/files/${ this.projectId }/${ filePath }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _writeUrl( filePath: string ): string
|
||||||
|
{
|
||||||
|
if ( this.localRoot )
|
||||||
|
return `/api/local/write?root=${ encodeURIComponent( this.localRoot ) }&path=${ encodeURIComponent( filePath ) }`;
|
||||||
|
if ( this.remoteProject )
|
||||||
|
return `/api/remote/files/${ this.remoteProject }/${ filePath }`;
|
||||||
|
return `/api/files/${ this.projectId }/${ filePath }`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="128"
|
||||||
|
height="128"
|
||||||
|
viewBox="0 0 128 128"
|
||||||
|
version="1.1"
|
||||||
|
id="svg5"
|
||||||
|
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
|
||||||
|
sodipodi:docname="directory.svg"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||||
|
id="namedview7"
|
||||||
|
pagecolor="#333333"
|
||||||
|
bordercolor="#404040"
|
||||||
|
borderopacity="1"
|
||||||
|
inkscape:showpageshadow="0"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#333333"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="3.0153862"
|
||||||
|
inkscape:cx="45.267833"
|
||||||
|
inkscape:cy="65.994863"
|
||||||
|
inkscape:window-width="1920"
|
||||||
|
inkscape:window-height="1017"
|
||||||
|
inkscape:window-x="-8"
|
||||||
|
inkscape:window-y="-8"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1" /><defs
|
||||||
|
id="defs2"><clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath7940"><rect
|
||||||
|
style="fill:#333333;fill-opacity:1;stroke:none;stroke-width:10;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
|
||||||
|
id="rect7942"
|
||||||
|
width="1440"
|
||||||
|
height="810"
|
||||||
|
x="0"
|
||||||
|
y="0" /></clipPath></defs><g
|
||||||
|
inkscape:label="Content"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
style="fill:#ffffff;fill-opacity:1"><path
|
||||||
|
id="rect298"
|
||||||
|
style="fill:#ddc849;fill-opacity:0.157647;stroke:#ffcd2c;stroke-width:4.62941;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke"
|
||||||
|
d="m 18.939219,17.937676 c -3.603915,0 -6.506489,2.900765 -6.506489,6.504681 0.269989,29.955359 0.920457,48.124236 0.920457,78.230983 0,4.09359 3.295382,7.38898 7.38897,7.38898 H 108.1783 c 4.09359,0 7.38897,-3.29539 7.38897,-7.38898 V 37.536713 c 0,-4.093587 -3.29538,-7.390779 -7.38897,-7.390779 H 88.478008 v -5.703577 c 0,-3.603916 -2.900765,-6.504681 -6.504682,-6.504681 z"
|
||||||
|
sodipodi:nodetypes="sccssssscsss" /><ellipse
|
||||||
|
style="fill:#ffe027;fill-opacity:0.157647;stroke:none;stroke-width:6.92884;stroke-linecap:round;stroke-linejoin:round;paint-order:fill markers stroke"
|
||||||
|
id="circle9716"
|
||||||
|
cx="49.920033"
|
||||||
|
cy="70.388992"
|
||||||
|
rx="9.368618"
|
||||||
|
ry="17.991062" /><ellipse
|
||||||
|
style="fill:#ffe027;fill-opacity:0.157647;stroke:none;stroke-width:6.92884;stroke-linecap:round;stroke-linejoin:round;paint-order:fill markers stroke"
|
||||||
|
id="circle9718"
|
||||||
|
cx="77.353271"
|
||||||
|
cy="70.388992"
|
||||||
|
rx="9.368618"
|
||||||
|
ry="17.991062" /></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
|
|
@ -0,0 +1,65 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="128"
|
||||||
|
height="128"
|
||||||
|
viewBox="0 0 128 128"
|
||||||
|
version="1.1"
|
||||||
|
id="svg5"
|
||||||
|
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
|
||||||
|
sodipodi:docname="file.svg"
|
||||||
|
xml:space="preserve"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
|
||||||
|
id="namedview7"
|
||||||
|
pagecolor="#333333"
|
||||||
|
bordercolor="#404040"
|
||||||
|
borderopacity="1"
|
||||||
|
inkscape:showpageshadow="0"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#333333"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
showgrid="false"
|
||||||
|
inkscape:zoom="2.1322"
|
||||||
|
inkscape:cx="-29.077948"
|
||||||
|
inkscape:cy="122.40878"
|
||||||
|
inkscape:window-width="1920"
|
||||||
|
inkscape:window-height="1017"
|
||||||
|
inkscape:window-x="-8"
|
||||||
|
inkscape:window-y="-8"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="g3398" /><defs
|
||||||
|
id="defs2"><clipPath
|
||||||
|
clipPathUnits="userSpaceOnUse"
|
||||||
|
id="clipPath7940"><rect
|
||||||
|
style="fill:#333333;fill-opacity:1;stroke:none;stroke-width:10;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
|
||||||
|
id="rect7942"
|
||||||
|
width="1440"
|
||||||
|
height="810"
|
||||||
|
x="0"
|
||||||
|
y="0" /></clipPath></defs><g
|
||||||
|
inkscape:label="Content"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
style="fill:#ffffff;fill-opacity:1"><g
|
||||||
|
id="g3398"><path
|
||||||
|
id="rect3203"
|
||||||
|
style="fill:#9d9a89;fill-opacity:0.157647;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke"
|
||||||
|
d="m 33.511719,9.4472656 c -4.421285,0 -7.980469,3.5591834 -7.980469,7.9804684 v 93.144536 c 0,4.42128 3.559184,7.98046 7.980469,7.98046 h 60.976562 c 4.421285,0 7.980469,-3.55918 7.980469,-7.98046 V 33.613281 L 82.509766,9.4472656 Z" /><circle
|
||||||
|
id="path3388"
|
||||||
|
style="fill:#9d9a89;stroke:#ffffff;stroke-opacity:1"
|
||||||
|
cx="80.902351"
|
||||||
|
cy="9.0282335"
|
||||||
|
r="0.097610451" /><circle
|
||||||
|
id="path3390"
|
||||||
|
style="fill:#9d9a89;stroke:#ffffff;stroke-opacity:1"
|
||||||
|
cx="82.543854"
|
||||||
|
cy="10.435231"
|
||||||
|
r="0.097610451" /><path
|
||||||
|
style="fill:#9d9a89;fill-opacity:0.157647;stroke:#ffffff;stroke-width:5;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1;paint-order:fill markers stroke"
|
||||||
|
d="M 81.723103,10.317981 V 33.885189 H 102.35907"
|
||||||
|
id="path3392" /></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
|
|
@ -11,6 +11,8 @@ import rojosRouter from './routes/rojos';
|
||||||
import userSettingsRouter from './routes/userSettings';
|
import userSettingsRouter from './routes/userSettings';
|
||||||
import { generateLocales } from './localeGenerator';
|
import { generateLocales } from './localeGenerator';
|
||||||
import deployRouter from './routes/deploy';
|
import deployRouter from './routes/deploy';
|
||||||
|
import localFilesRouter from './routes/localFiles';
|
||||||
|
import remoteProxyRouter from './routes/remoteProxy';
|
||||||
import { EmailService } from './email/EmailService';
|
import { EmailService } from './email/EmailService';
|
||||||
|
|
||||||
generateLocales();
|
generateLocales();
|
||||||
|
|
@ -35,6 +37,11 @@ app.use( '/api/user/settings', userSettingsRouter );
|
||||||
|
|
||||||
app.get( '/api/auth/me', requireAuth, ( req, res ) => res.json( req.auth ) );
|
app.get( '/api/auth/me', requireAuth, ( req, res ) => res.json( req.auth ) );
|
||||||
|
|
||||||
|
if ( process.env.ROJECT_ELECTRON === 'true' ) {
|
||||||
|
app.use( '/api/local', localFilesRouter );
|
||||||
|
app.use( '/api/remote', remoteProxyRouter );
|
||||||
|
}
|
||||||
|
|
||||||
app.get( '/edit', ( _req, res ) => res.redirect( '/editor.html' ) );
|
app.get( '/edit', ( _req, res ) => res.redirect( '/editor.html' ) );
|
||||||
|
|
||||||
export function startServer( port: number ): void {
|
export function startServer( port: number ): void {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { Router } from 'express';
|
||||||
|
import { requireAuth } from '../../auth-connector/source/server/auth';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use( requireAuth );
|
||||||
|
|
||||||
|
interface FileNode
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: 'file' | 'directory';
|
||||||
|
children?: FileNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeResolve( root: string, filePath: string ): string | null
|
||||||
|
{
|
||||||
|
const resolvedRoot = path.resolve( root );
|
||||||
|
const full = path.resolve( path.join( resolvedRoot, filePath ) );
|
||||||
|
if ( !full.startsWith( resolvedRoot + path.sep ) && full !== resolvedRoot ) return null;
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTree( absDir: string, rootDir: string ): FileNode[]
|
||||||
|
{
|
||||||
|
return fs.readdirSync( absDir ).map( name =>
|
||||||
|
{
|
||||||
|
const abs = path.join( absDir, name );
|
||||||
|
const rel = path.relative( rootDir, abs ).replace( /\\/g, '/' );
|
||||||
|
if ( fs.statSync( abs ).isDirectory() )
|
||||||
|
{
|
||||||
|
return { name, path: rel, type: 'directory' as const, children: buildTree( abs, rootDir ) };
|
||||||
|
}
|
||||||
|
return { name, path: rel, type: 'file' as const };
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get( '/tree', ( req, res ) =>
|
||||||
|
{
|
||||||
|
const root = req.query.root as string;
|
||||||
|
if ( !root ) { res.status( 400 ).json( { error: 'root required' } ); return; }
|
||||||
|
const resolvedRoot = path.resolve( root );
|
||||||
|
if ( !fs.existsSync( resolvedRoot ) ) { res.status( 404 ).json( { error: 'Directory not found' } ); return; }
|
||||||
|
res.json( buildTree( resolvedRoot, resolvedRoot ) );
|
||||||
|
} );
|
||||||
|
|
||||||
|
router.get( '/read', ( req, res ) =>
|
||||||
|
{
|
||||||
|
const root = req.query.root as string;
|
||||||
|
const filePath = req.query.path as string;
|
||||||
|
if ( !root || !filePath ) { res.status( 400 ).json( { error: 'root and path required' } ); return; }
|
||||||
|
const full = safeResolve( root, filePath );
|
||||||
|
if ( !full || !fs.existsSync( full ) ) { res.status( 404 ).json( { error: 'Not found' } ); return; }
|
||||||
|
res.type( 'text/plain' ).send( fs.readFileSync( full, 'utf8' ) );
|
||||||
|
} );
|
||||||
|
|
||||||
|
router.put( '/write', ( req, res ) =>
|
||||||
|
{
|
||||||
|
const root = req.query.root as string;
|
||||||
|
const filePath = req.query.path as string;
|
||||||
|
if ( !root || !filePath ) { res.status( 400 ).json( { error: 'root and path required' } ); return; }
|
||||||
|
if ( typeof req.body !== 'string' ) { res.status( 400 ).json( { error: 'Content must be text' } ); return; }
|
||||||
|
const full = safeResolve( root, filePath );
|
||||||
|
if ( !full ) { res.status( 403 ).json( { error: 'Invalid path' } ); return; }
|
||||||
|
fs.mkdirSync( path.dirname( full ), { recursive: true } );
|
||||||
|
fs.writeFileSync( full, req.body, 'utf8' );
|
||||||
|
res.json( { ok: true } );
|
||||||
|
} );
|
||||||
|
|
||||||
|
router.post( '/create-file', ( req, res ) =>
|
||||||
|
{
|
||||||
|
const { root, path: filePath } = req.body as { root: string; path: string };
|
||||||
|
if ( !root || !filePath ) { res.status( 400 ).json( { error: 'root and path required' } ); return; }
|
||||||
|
const full = safeResolve( root, filePath );
|
||||||
|
if ( !full || fs.existsSync( full ) ) { res.status( 409 ).json( { error: 'Already exists or invalid path' } ); return; }
|
||||||
|
fs.mkdirSync( path.dirname( full ), { recursive: true } );
|
||||||
|
fs.writeFileSync( full, '', 'utf8' );
|
||||||
|
res.json( { ok: true } );
|
||||||
|
} );
|
||||||
|
|
||||||
|
router.post( '/create-directory', ( req, res ) =>
|
||||||
|
{
|
||||||
|
const { root, path: dirPath } = req.body as { root: string; path: string };
|
||||||
|
if ( !root || !dirPath ) { res.status( 400 ).json( { error: 'root and path required' } ); return; }
|
||||||
|
const full = safeResolve( root, dirPath );
|
||||||
|
if ( !full || fs.existsSync( full ) ) { res.status( 409 ).json( { error: 'Already exists or invalid path' } ); return; }
|
||||||
|
fs.mkdirSync( full, { recursive: true } );
|
||||||
|
res.json( { ok: true } );
|
||||||
|
} );
|
||||||
|
|
||||||
|
router.post( '/rename', ( req, res ) =>
|
||||||
|
{
|
||||||
|
const { root, path: oldPath, newName } = req.body as { root: string; path: string; newName: string };
|
||||||
|
if ( !root || !oldPath || !newName ) { res.status( 400 ).json( { error: 'root, path, and newName required' } ); return; }
|
||||||
|
if ( newName.includes( '/' ) || newName.includes( '\\' ) ) { res.status( 400 ).json( { error: 'Invalid name' } ); return; }
|
||||||
|
const full = safeResolve( root, oldPath );
|
||||||
|
if ( !full || !fs.existsSync( full ) ) { res.status( 404 ).json( { error: 'Not found' } ); return; }
|
||||||
|
const resolvedRoot = path.resolve( root );
|
||||||
|
const newFull = path.join( path.dirname( full ), newName );
|
||||||
|
if ( !newFull.startsWith( resolvedRoot + path.sep ) ) { res.status( 403 ).json( { error: 'Invalid path' } ); return; }
|
||||||
|
if ( fs.existsSync( newFull ) ) { res.status( 409 ).json( { error: 'Already exists' } ); return; }
|
||||||
|
fs.renameSync( full, newFull );
|
||||||
|
res.json( { ok: true } );
|
||||||
|
} );
|
||||||
|
|
||||||
|
router.post( '/delete', ( req, res ) =>
|
||||||
|
{
|
||||||
|
const { root, path: targetPath } = req.body as { root: string; path: string };
|
||||||
|
if ( !root || !targetPath ) { res.status( 400 ).json( { error: 'root and path required' } ); return; }
|
||||||
|
const full = safeResolve( root, targetPath );
|
||||||
|
if ( !full || !fs.existsSync( full ) ) { res.status( 404 ).json( { error: 'Not found' } ); return; }
|
||||||
|
fs.rmSync( full, { recursive: true, force: true } );
|
||||||
|
res.json( { ok: true } );
|
||||||
|
} );
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { requireAuth } from '../../auth-connector/source/server/auth';
|
||||||
|
import https from 'https';
|
||||||
|
|
||||||
|
const REMOTE_HOST = 'roject.rokojori.com';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use( requireAuth );
|
||||||
|
|
||||||
|
router.all( '*', ( req: Request, res: Response ) => {
|
||||||
|
const remotePath = '/api' + req.path;
|
||||||
|
const qs = req.url.slice( req.path.length );
|
||||||
|
|
||||||
|
const token = ( req.headers.authorization as string ) ?? '';
|
||||||
|
|
||||||
|
let bodyBuffer: Buffer | undefined;
|
||||||
|
if ( req.body !== undefined && ( req.method === 'POST' || req.method === 'PUT' ) ) {
|
||||||
|
bodyBuffer = typeof req.body === 'string'
|
||||||
|
? Buffer.from( req.body, 'utf8' )
|
||||||
|
: Buffer.from( JSON.stringify( req.body ), 'utf8' );
|
||||||
|
}
|
||||||
|
|
||||||
|
const reqHeaders: Record<string, string | number> = { 'Authorization': token };
|
||||||
|
const ct = req.headers[ 'content-type' ];
|
||||||
|
if ( ct ) reqHeaders[ 'Content-Type' ] = ct;
|
||||||
|
if ( bodyBuffer ) reqHeaders[ 'Content-Length' ] = bodyBuffer.length;
|
||||||
|
|
||||||
|
const proxyReq = https.request(
|
||||||
|
{ hostname: REMOTE_HOST, path: remotePath + qs, method: req.method, headers: reqHeaders },
|
||||||
|
( proxyRes ) => {
|
||||||
|
res.status( proxyRes.statusCode ?? 200 );
|
||||||
|
const resCt = proxyRes.headers[ 'content-type' ];
|
||||||
|
if ( resCt ) res.setHeader( 'Content-Type', resCt );
|
||||||
|
proxyRes.pipe( res );
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
proxyReq.on( 'error', ( err ) => {
|
||||||
|
res.status( 502 ).json( { error: 'Remote server unreachable', detail: err.message } );
|
||||||
|
} );
|
||||||
|
|
||||||
|
if ( bodyBuffer ) proxyReq.write( bodyBuffer );
|
||||||
|
proxyReq.end();
|
||||||
|
} );
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -52,6 +52,7 @@ var NAV_DATA = {
|
||||||
title: 'History',
|
title: 'History',
|
||||||
path: 'history/index.html',
|
path: 'history/index.html',
|
||||||
children: [
|
children: [
|
||||||
|
{ title: 'Wednesday, 30 July 2026', path: 'history/2026/07-July/30-Wednesday/index.html' },
|
||||||
{ title: 'Friday, 25 July 2026', path: 'history/2026/07-July/25-Friday/index.html' },
|
{ title: 'Friday, 25 July 2026', path: 'history/2026/07-July/25-Friday/index.html' },
|
||||||
{ title: 'Friday, 18 July 2026', path: 'history/2026/07-July/18-Friday/index.html' },
|
{ title: 'Friday, 18 July 2026', path: 'history/2026/07-July/18-Friday/index.html' },
|
||||||
{ title: 'Wednesday, 16 July 2026', path: 'history/2026/07-July/16-Wednesday/index.html' },
|
{ title: 'Wednesday, 16 July 2026', path: 'history/2026/07-July/16-Wednesday/index.html' },
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,16 @@
|
||||||
<div class="lane">
|
<div class="lane">
|
||||||
<div class="lane-header">Critical</div>
|
<div class="lane-header">Critical</div>
|
||||||
|
|
||||||
|
<task-item class="red hide-content">
|
||||||
|
<task-title>Electron: Check authentication problems in online projects</task-title>
|
||||||
|
<task-content>
|
||||||
|
After a while, authentication for online projects in the Electron app stops working.
|
||||||
|
The remote proxy forwards the Authorization header injected by onBeforeSendHeaders,
|
||||||
|
but the token may expire without a refresh cycle being triggered in the Electron context.
|
||||||
|
Investigate token refresh timing and whether the Electron token injector re-reads
|
||||||
|
a refreshed token or still sends the original one.
|
||||||
|
</task-content>
|
||||||
|
</task-item>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,15 +103,6 @@
|
||||||
</task-content>
|
</task-content>
|
||||||
</task-item>
|
</task-item>
|
||||||
|
|
||||||
<task-item class="blue hide-content">
|
|
||||||
<task-title>Remote Projects in Electron</task-title>
|
|
||||||
<task-content>
|
|
||||||
Allow the Electron app to connect to roject.rokojori.com and list remote projects
|
|
||||||
alongside local ones. The JWT is already available;
|
|
||||||
it's a matter of pointing requests at the remote URL with the token.
|
|
||||||
</task-content>
|
|
||||||
</task-item>
|
|
||||||
|
|
||||||
<task-item class="blue hide-content">
|
<task-item class="blue hide-content">
|
||||||
<task-title>Unauthenticated Landing Screen</task-title>
|
<task-title>Unauthenticated Landing Screen</task-title>
|
||||||
<task-content>
|
<task-content>
|
||||||
|
|
@ -225,19 +216,34 @@
|
||||||
</task-content>
|
</task-content>
|
||||||
</task-item>
|
</task-item>
|
||||||
|
|
||||||
<task-item class="yellow hide-content">
|
|
||||||
<task-title>Local Filesystem Access</task-title>
|
|
||||||
<task-content>
|
|
||||||
Extend the file tree to browse arbitrary directories on the host
|
|
||||||
machine using Node.js fs rather than the server's JSON-backed project storage.
|
|
||||||
</task-content>
|
|
||||||
</task-item>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lane">
|
<div class="lane">
|
||||||
<div class="lane-header">Done</div>
|
<div class="lane-header">Done</div>
|
||||||
|
|
||||||
|
<task-item class="green hide-content">
|
||||||
|
<task-title>Remote Projects in Electron</task-title>
|
||||||
|
<task-content>
|
||||||
|
Implemented via a reverse proxy route: /api/remote/** strips the prefix,
|
||||||
|
prepends /api, and forwards to roject.rokojori.com over HTTPS.
|
||||||
|
The Electron onBeforeSendHeaders interceptor already injects the Authorization
|
||||||
|
header on all localhost:3000 requests, so no new IPC channel was needed.
|
||||||
|
The Online tab in project-list-default fetches from /api/remote/projects
|
||||||
|
and opens the editor with a remoteProject URL param.
|
||||||
|
</task-content>
|
||||||
|
</task-item>
|
||||||
|
|
||||||
|
<task-item class="green hide-content">
|
||||||
|
<task-title>Local Filesystem Access</task-title>
|
||||||
|
<task-content>
|
||||||
|
File tree now browses arbitrary host directories via /api/local/tree, /api/local/read,
|
||||||
|
and /api/local/write routes (Node.js fs, no project storage). Editor.ts branches on
|
||||||
|
localRoot for all read/write URLs. The "This PC" tab in project-list-default opens
|
||||||
|
the editor with a localRoot URL param.
|
||||||
|
</task-content>
|
||||||
|
</task-item>
|
||||||
|
|
||||||
<task-item class="green hide-content">
|
<task-item class="green hide-content">
|
||||||
<task-title>Tab-container: split function broken, panel border update unreliable</task-title>
|
<task-title>Tab-container: split function broken, panel border update unreliable</task-title>
|
||||||
<task-content>
|
<task-content>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Wednesday, 30 July 2026 — Roject</title>
|
||||||
|
<link rel="stylesheet" href="../../../../_assets_/styles.css">
|
||||||
|
<link rel="stylesheet" href="../../../../_assets_/nav.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<p class="date">Wednesday, 30 July 2026</p>
|
||||||
|
<h1>Session History</h1>
|
||||||
|
<p class="subtitle">Local filesystem access, remote projects proxy, file tree custom elements + icons, project-list-default tab UI.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>What we built</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>project-list-default — tab UI and folder rows</h3>
|
||||||
|
<p>
|
||||||
|
The navigation in <code>project-list-default</code> now uses proper custom elements:
|
||||||
|
<code><pld-tabs></code> and <code><pld-tab></code> (previously
|
||||||
|
<code><div class="pld-tabs"></code> / <code><button class="pld-tab"></code>).
|
||||||
|
The tabs are centred in the nav bar via
|
||||||
|
<code>position: absolute; left: 50%; transform: translateX(-50%)</code>
|
||||||
|
without interfering with the logo.
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
The <em>This PC</em> tab row was rewritten to use the same style as online project rows:
|
||||||
|
<code>pld-name</code> class, Barlow 5em weight, and a seeded hue via
|
||||||
|
<code>hueFromId(folder)</code> so each local folder gets a deterministic colour.
|
||||||
|
The subtitle / path line was removed for visual consistency.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Local Filesystem Access — completed</h3>
|
||||||
|
<p>
|
||||||
|
The Electron app can now open any host directory as an editor workspace.
|
||||||
|
The <em>This PC</em> tab lets the user browse and select a local folder;
|
||||||
|
clicking opens the editor with a <code>localRoot</code> URL param.
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
<code>Editor.ts</code> gained a <code>localRoot</code> field and private
|
||||||
|
<code>_readUrl</code> / <code>_writeUrl</code> helpers that branch on
|
||||||
|
<code>localRoot</code> → <code>remoteProject</code> → <code>projectId</code>.
|
||||||
|
<code>editor-shell.ts</code> reads the <code>localRoot</code> URL param and sets it
|
||||||
|
on the <code>Editor</code> singleton. <code>file-tree-panel.ts</code> branches on
|
||||||
|
<code>localRoot</code> for all tree, read, write, rename, and delete operations,
|
||||||
|
routing through the <code>/api/local/</code> routes (Node.js <code>fs</code>).
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
Root cause of the prior <code>/api/files//tree 404</code> error: <code>projectId</code>
|
||||||
|
was <code>''</code> when only <code>localRoot</code> was set, producing a double slash
|
||||||
|
in the URL. Fixed by the three-branch URL helper.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Remote Projects in Electron — proxy approach</h3>
|
||||||
|
<p>
|
||||||
|
Online projects from <code>roject.rokojori.com</code> are now accessible in the
|
||||||
|
Electron app via a server-side reverse proxy. <code>source/server/routes/remoteProxy.ts</code>
|
||||||
|
is a catch-all router mounted at <code>/api/remote</code>: it strips the prefix,
|
||||||
|
prepends <code>/api</code>, and forwards the request to
|
||||||
|
<code>roject.rokojori.com</code> over HTTPS.
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
Authentication is automatic: the Electron <code>onBeforeSendHeaders</code> interceptor
|
||||||
|
already injects <code>Authorization: Bearer <token></code> into every
|
||||||
|
<code>localhost:3000</code> request, so the proxy receives the header and forwards it
|
||||||
|
upstream with no new IPC channel needed.
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
<code>project-list-default</code> gained an <code>_isRemoteOnline</code> flag and a
|
||||||
|
<code>_projectUrl()</code> helper that routes all API calls to <code>/api/remote/projects</code>
|
||||||
|
when the Online tab is active. Row clicks open the editor with a <code>remoteProject</code>
|
||||||
|
URL param; <code>Editor.ts</code> / <code>editor-shell.ts</code> read and apply it
|
||||||
|
the same way as <code>localRoot</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>file-tree-panel — custom elements, icons, closed-by-default dirs</h3>
|
||||||
|
<p>
|
||||||
|
All structural HTML in the file tree was converted to hyphenated custom elements:
|
||||||
|
<code><ftp-header></code>, <code><ftp-tree></code>, <code><ftp-list></code>,
|
||||||
|
<code><ftp-dir></code>, <code><ftp-dir-label></code>,
|
||||||
|
<code><ftp-file></code>, <code><ftp-inline-create></code>,
|
||||||
|
<code><ftp-inline-label></code>, <code><ftp-type-error></code>,
|
||||||
|
<code><ftp-empty></code>, <code><ftp-up></code>.
|
||||||
|
CSS selectors, DOM queries, and <code>closest()</code> calls updated throughout
|
||||||
|
(e.g. <code>el.closest('ftp-dir')</code> instead of <code>el.closest('li')</code>).
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
Directories now start <strong>closed</strong>. A CSS triangle indicator (border trick,
|
||||||
|
no character) on <code>ftp-dir-label::before</code> points right when closed and
|
||||||
|
rotates 90° on <code>.open</code>. Directories are sorted before files in
|
||||||
|
<code>renderNodes</code>. Files and directories get SVG icons via
|
||||||
|
<code><img src="/icons/..."></code>; the same icons appear in
|
||||||
|
<code>tab-container</code> header tabs. SVG sources live in <code>source/icons/</code>
|
||||||
|
and are copied to <code>build/app/icons/</code> by <code>scripts/copy-pages.js</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Key decisions</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<p>
|
||||||
|
<strong>Server-side proxy over dual-window approach for remote projects.</strong>
|
||||||
|
Option A (opening a second BrowserWindow pointed at the live site) would have
|
||||||
|
introduced version skew risk when the local Electron build diverges from the server.
|
||||||
|
Option B (proxy at <code>/api/remote/**</code>) keeps a single frontend, single
|
||||||
|
server, and lets <code>onBeforeSendHeaders</code> handle auth automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<p>
|
||||||
|
<strong>Three-branch URL helper instead of hardcoded paths.</strong>
|
||||||
|
<code>_readUrl</code> / <code>_writeUrl</code> check <code>localRoot</code> first,
|
||||||
|
then <code>remoteProject</code>, then fall back to <code>projectId</code>. This keeps
|
||||||
|
all routing in one place and prevents the double-slash <code>/api/files//tree</code>
|
||||||
|
error that occurred when <code>projectId</code> was empty.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<p>
|
||||||
|
<strong>Hyphenated custom element names for all structural markup.</strong>
|
||||||
|
No <code><div class="name"></code> pattern anywhere in the file tree or
|
||||||
|
project list. CSS uses the element tag as the root selector, keeping selectors
|
||||||
|
short and scoped without class name collisions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
Roject — session history
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<script>var NAV_ROOT = '../../../../';</script>
|
||||||
|
<script src="../../../../_assets_/nav-data.js"></script>
|
||||||
|
<script src="../../../../_assets_/nav.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -19,6 +19,11 @@
|
||||||
<section>
|
<section>
|
||||||
<h2>2026 — July</h2>
|
<h2>2026 — July</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3><a href="2026/07-July/30-Wednesday/index.html">Wednesday, 30 July 2026</a></h3>
|
||||||
|
<p>Local filesystem access completed (localRoot URL param, /api/local/ routes, three-branch URL helper in Editor). Remote projects proxy in Electron (/api/remote/** → roject.rokojori.com, auth via onBeforeSendHeaders). file-tree-panel fully converted to custom elements (ftp-*), closed-by-default dirs, CSS triangle, SVG icons. project-list-default tab UI with <pld-tabs>/<pld-tab> custom elements.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3><a href="2026/07-July/25-Friday/index.html">Friday, 25 July 2026</a></h3>
|
<h3><a href="2026/07-July/25-Friday/index.html">Friday, 25 July 2026</a></h3>
|
||||||
<p>Tab container: split submenu (horizontal/vertical), close container with unsaved-changes dialog, middle-mouse tab close. EditorPanel/FileEditorPanel interface system replacing TabEntry.dirty. Section resize and portrait→landscape bug fixes. TypeScript guide: custom element names + web component interface pattern. CLAUDE.md and workspace index corrected.</p>
|
<p>Tab container: split submenu (horizontal/vertical), close container with unsaved-changes dialog, middle-mouse tab close. EditorPanel/FileEditorPanel interface system replacing TabEntry.dirty. Section resize and portrait→landscape bug fixes. TypeScript guide: custom element names + web component interface pattern. CLAUDE.md and workspace index corrected.</p>
|
||||||
|
|
|
||||||
|
|
@ -398,6 +398,22 @@
|
||||||
<code>session.webRequest.onBeforeSendHeaders</code>. Run with
|
<code>session.webRequest.onBeforeSendHeaders</code>. Run with
|
||||||
<code>npm run electron:dev</code>.
|
<code>npm run electron:dev</code>.
|
||||||
</p>
|
</p>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
<strong>Local filesystem access:</strong> when <code>ROJECT_ELECTRON=true</code>,
|
||||||
|
the server mounts <code>/api/local/</code> routes backed by Node.js <code>fs</code>
|
||||||
|
(no project storage). The project-list-default <em>This PC</em> tab lets the user
|
||||||
|
pick any host directory; the editor opens with a <code>localRoot</code> URL param
|
||||||
|
and all file-tree operations route through <code>/api/local/</code>.
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:0.75rem">
|
||||||
|
<strong>Remote project proxy:</strong> <code>/api/remote/**</code> is a catch-all
|
||||||
|
that strips the prefix, prepends <code>/api</code>, and forwards the request to
|
||||||
|
<code>roject.rokojori.com</code> over HTTPS. The <code>Authorization</code> header
|
||||||
|
is already injected by <code>onBeforeSendHeaders</code>, so no extra IPC channel is
|
||||||
|
needed. The project-list-default <em>Online</em> tab fetches from
|
||||||
|
<code>/api/remote/projects</code> and opens the editor with a <code>remoteProject</code>
|
||||||
|
URL param.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue