initial commit: source, workspace docs, locale system, gitignore
This commit is contained in:
commit
1dc40fa22c
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
data/
|
||||
storage/
|
||||
public/
|
||||
!public/*.html
|
||||
tsconfig.client.tsbuildinfo
|
||||
.claude/
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "src/library-ts"]
|
||||
path = src/library-ts
|
||||
url = git@development.rokojori.com:Josef/library-ts.git
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Roject
|
||||
|
||||
See **[workspace/outline/index.html](workspace/outline/index.html)** for the full developer documentation — project outline, tech stack, coding conventions, and repeatable actions.
|
||||
|
|
@ -0,0 +1 @@
|
|||
Add file
|
||||
|
|
@ -0,0 +1 @@
|
|||
Hello
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "roject",
|
||||
"version": "1.0.0",
|
||||
"main": "server/index.ts",
|
||||
"scripts": {
|
||||
"start": "npm run build && ts-node --project tsconfig.ts-node.json server/index.ts",
|
||||
"build": "tsc --build tsconfig.client.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.17.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-session": "^1.17.10",
|
||||
"@types/node": "^20.11.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const DATA_DIR = path.join(__dirname, '..', 'data');
|
||||
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR);
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
password: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface GroupMember {
|
||||
id: string;
|
||||
group_id: string;
|
||||
member_type: 'user' | 'group';
|
||||
member_id: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
owner_id: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ProjectMember {
|
||||
id: string;
|
||||
project_id: string;
|
||||
member_type: 'user' | 'group';
|
||||
member_id: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
function read<T>(table: string): T[] {
|
||||
const file = path.join(DATA_DIR, `${table}.json`);
|
||||
if (!fs.existsSync(file)) return [];
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8')) as T[];
|
||||
}
|
||||
|
||||
function write<T>(table: string, rows: T[]): void {
|
||||
fs.writeFileSync(path.join(DATA_DIR, `${table}.json`), JSON.stringify(rows, null, 2));
|
||||
}
|
||||
|
||||
export const users = {
|
||||
all: (): User[] => read<User>('users'),
|
||||
findById: (id: string): User | undefined => read<User>('users').find(u => u.id === id),
|
||||
findByUsername: (name: string): User | undefined => read<User>('users').find(u => u.username === name),
|
||||
create(data: Omit<User, 'id' | 'created_at'>): User {
|
||||
const rows = read<User>('users');
|
||||
if (rows.find(u => u.username === data.username)) throw new Error('Taken');
|
||||
const user: User = { id: randomUUID(), ...data, created_at: new Date().toISOString() };
|
||||
write('users', [...rows, user]);
|
||||
return user;
|
||||
},
|
||||
delete(id: string): void {
|
||||
write('users', read<User>('users').filter(u => u.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
export const groups = {
|
||||
all: (): Group[] => read<Group>('groups'),
|
||||
findById: (id: string): Group | undefined => read<Group>('groups').find(g => g.id === id),
|
||||
create(data: Omit<Group, 'id' | 'created_at'>): Group {
|
||||
const rows = read<Group>('groups');
|
||||
if (rows.find(g => g.name === data.name)) throw new Error('Taken');
|
||||
const group: Group = { id: randomUUID(), ...data, created_at: new Date().toISOString() };
|
||||
write('groups', [...rows, group]);
|
||||
return group;
|
||||
},
|
||||
delete(id: string): void {
|
||||
write('groups', read<Group>('groups').filter(g => g.id !== id));
|
||||
write('group_members', read<GroupMember>('group_members').filter(m => m.group_id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
export const groupMembers = {
|
||||
forGroup: (groupId: string): GroupMember[] => read<GroupMember>('group_members').filter(m => m.group_id === groupId),
|
||||
add(data: Omit<GroupMember, 'id'>): GroupMember {
|
||||
const rows = read<GroupMember>('group_members');
|
||||
const member: GroupMember = { id: randomUUID(), ...data };
|
||||
write('group_members', [...rows, member]);
|
||||
return member;
|
||||
},
|
||||
remove(id: string): void {
|
||||
write('group_members', read<GroupMember>('group_members').filter(m => m.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
export const projects = {
|
||||
all: (): Project[] => read<Project>('projects'),
|
||||
findById: (id: string): Project | undefined => read<Project>('projects').find(p => p.id === id),
|
||||
create(data: Omit<Project, 'id' | 'created_at'>): Project {
|
||||
const rows = read<Project>('projects');
|
||||
const project: Project = { id: randomUUID(), ...data, created_at: new Date().toISOString() };
|
||||
write('projects', [...rows, project]);
|
||||
return project;
|
||||
},
|
||||
delete(id: string): void {
|
||||
write('projects', read<Project>('projects').filter(p => p.id !== id));
|
||||
write('project_members', read<ProjectMember>('project_members').filter(m => m.project_id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
export const projectMembers = {
|
||||
forProject: (projectId: string): ProjectMember[] => read<ProjectMember>('project_members').filter(m => m.project_id === projectId),
|
||||
add(data: Omit<ProjectMember, 'id'>): ProjectMember {
|
||||
const rows = read<ProjectMember>('project_members');
|
||||
const member: ProjectMember = { id: randomUUID(), ...data };
|
||||
write('project_members', [...rows, member]);
|
||||
return member;
|
||||
},
|
||||
remove(id: string): void {
|
||||
write('project_members', read<ProjectMember>('project_members').filter(m => m.id !== id));
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
import path from 'path';
|
||||
import { JsonSessionStore } from './sessionStore';
|
||||
import authRouter from './routes/auth';
|
||||
import groupsRouter from './routes/groups';
|
||||
import projectsRouter from './routes/projects';
|
||||
import filesRouter from './routes/files';
|
||||
import localesRouter from './routes/locales';
|
||||
import layoutRouter from './routes/layout';
|
||||
import { generateLocales } from './localeGenerator';
|
||||
|
||||
generateLocales();
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use( express.json() );
|
||||
app.use( express.text( { type: 'text/plain' } ) );
|
||||
app.use( session(
|
||||
{
|
||||
store: new JsonSessionStore( path.join( __dirname, '..', 'storage', 'sessions' ) ),
|
||||
secret: 'roject-secret-key',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: { maxAge: 7 * 24 * 60 * 60 * 1000 }
|
||||
} ) );
|
||||
|
||||
app.use( express.static( path.join( __dirname, '..', 'public' ) ) );
|
||||
|
||||
app.use( '/api/auth', authRouter );
|
||||
app.use( '/api/groups', groupsRouter );
|
||||
app.use( '/api/projects', projectsRouter );
|
||||
app.use( '/api/files', filesRouter );
|
||||
app.use( '/api/locales', localesRouter );
|
||||
app.use( '/api/layout', layoutRouter );
|
||||
|
||||
app.get( '/', ( req, res ) =>
|
||||
{
|
||||
res.redirect( req.session.userId ? '/dashboard.html' : '/login.html' );
|
||||
} );
|
||||
|
||||
const PORT = process.env.PORT ? Number( process.env.PORT ) : 3000;
|
||||
app.listen( PORT, () => console.log( `Roject running on http://localhost:${PORT}` ) );
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const LOCALES_DIR = path.join( __dirname, '..', 'locales', 'en' );
|
||||
const GENERATED_DIR = path.join( __dirname, '..', 'src', 'locales', 'generated' );
|
||||
|
||||
function toPascalCase( name: string ): string
|
||||
{
|
||||
return name
|
||||
.split( /[-_]/ )
|
||||
.map( part => part.charAt( 0 ).toUpperCase() + part.slice( 1 ) )
|
||||
.join( '' );
|
||||
}
|
||||
|
||||
function toMemberName( fileName: string ): string
|
||||
{
|
||||
const withUnderscores = fileName.replace( /\./g, '_' );
|
||||
return withUnderscores.replace( /-([a-zA-Z])/g, ( _, c: string ) => c.toUpperCase() );
|
||||
}
|
||||
|
||||
function generateDir( absLocaleDir: string, enRoot: string, absOutDir: string, className: string ): void
|
||||
{
|
||||
const files: { memberName: string; relativePath: string }[] = [];
|
||||
const dirs: { dirName: string; childClassName: string }[] = [];
|
||||
|
||||
for ( const entry of fs.readdirSync( absLocaleDir ) )
|
||||
{
|
||||
if ( entry.endsWith( '.md' ) ) continue;
|
||||
|
||||
const abs = path.join( absLocaleDir, entry );
|
||||
|
||||
if ( fs.statSync( abs ).isDirectory() )
|
||||
{
|
||||
const childClassName = toPascalCase( entry );
|
||||
dirs.push( { dirName: entry, childClassName } );
|
||||
generateDir( abs, enRoot, path.join( absOutDir, entry ), childClassName );
|
||||
}
|
||||
else
|
||||
{
|
||||
const rel = path.relative( enRoot, abs ).replace( /\\/g, '/' );
|
||||
files.push( { memberName: toMemberName( entry ), relativePath: rel } );
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push( '// Auto-generated by localeGenerator — do not edit manually.' );
|
||||
|
||||
if ( dirs.length > 0 )
|
||||
{
|
||||
lines.push( '' );
|
||||
|
||||
for ( const { dirName, childClassName } of dirs )
|
||||
{
|
||||
lines.push( `import { ${childClassName} } from './${dirName}/${childClassName}.js';` );
|
||||
}
|
||||
}
|
||||
|
||||
lines.push( '' );
|
||||
lines.push( `export class ${className}` );
|
||||
lines.push( '{' );
|
||||
|
||||
for ( const { memberName, relativePath } of files )
|
||||
{
|
||||
lines.push( ` static readonly ${memberName} = '${relativePath}';` );
|
||||
}
|
||||
|
||||
for ( const { childClassName } of dirs )
|
||||
{
|
||||
lines.push( ` static readonly ${childClassName} = ${childClassName};` );
|
||||
}
|
||||
|
||||
lines.push( '}' );
|
||||
|
||||
fs.mkdirSync( absOutDir, { recursive: true } );
|
||||
fs.writeFileSync( path.join( absOutDir, `${className}.ts` ), lines.join( '\n' ) + '\n', 'utf8' );
|
||||
}
|
||||
|
||||
export function generateLocales(): void
|
||||
{
|
||||
if ( !fs.existsSync( LOCALES_DIR ) ) return;
|
||||
generateDir( LOCALES_DIR, LOCALES_DIR, GENERATED_DIR, 'Locales' );
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
userId?: string;
|
||||
username?: string;
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!req.session.userId) {
|
||||
res.status(401).json({ error: 'Not authenticated' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { users } from '../db';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/register', async (req, res) => {
|
||||
const { username, password } = req.body as { username?: string; password?: string };
|
||||
if (!username || !password) { res.status(400).json({ error: 'Username and password required' }); return; }
|
||||
try {
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
const user = users.create({ username, password: hash });
|
||||
req.session.userId = user.id;
|
||||
req.session.username = user.username;
|
||||
res.json({ id: user.id, username: user.username });
|
||||
} catch {
|
||||
res.status(409).json({ error: 'Username already taken' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
const { username, password } = req.body as { username?: string; password?: string };
|
||||
const user = username ? users.findByUsername(username) : undefined;
|
||||
if (!user || !password || !(await bcrypt.compare(password, user.password))) {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
return;
|
||||
}
|
||||
req.session.userId = user.id;
|
||||
req.session.username = user.username;
|
||||
res.json({ id: user.id, username: user.username });
|
||||
});
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
req.session.destroy(() => res.json({ ok: true }));
|
||||
});
|
||||
|
||||
router.delete('/me', requireAuth, (req, res) => {
|
||||
users.delete(req.session.userId!);
|
||||
req.session.destroy(() => res.json({ ok: true }));
|
||||
});
|
||||
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
res.json({ id: req.session.userId, username: req.session.username });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { Router } from 'express';
|
||||
import { getFileTree, readProjectFile, writeProjectFile, createProjectFile, createProjectDirectory, renameProjectEntry, deleteProjectEntry } from '../storage';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
router.get('/:projectId/tree', (req, res) => {
|
||||
res.json(getFileTree(req.params.projectId));
|
||||
});
|
||||
|
||||
router.get('/:projectId/*', (req, res) => {
|
||||
const filePath = (req.params as Record<string, string>)[0];
|
||||
const content = readProjectFile(req.params.projectId, filePath);
|
||||
if (content === null) { res.status(404).json({ error: 'Not found' }); return; }
|
||||
res.type('text/plain').send(content);
|
||||
});
|
||||
|
||||
router.post('/:projectId/create-file', (req, res) => {
|
||||
const { path: filePath } = req.body as { path: string };
|
||||
if (!filePath) { res.status(400).json({ error: 'path required' }); return; }
|
||||
const ok = createProjectFile(req.params.projectId, filePath);
|
||||
if (!ok) { res.status(409).json({ error: 'Already exists or invalid path' }); return; }
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/:projectId/create-directory', (req, res) => {
|
||||
const { path: dirPath } = req.body as { path: string };
|
||||
if (!dirPath) { res.status(400).json({ error: 'path required' }); return; }
|
||||
const ok = createProjectDirectory(req.params.projectId, dirPath);
|
||||
if (!ok) { res.status(409).json({ error: 'Already exists or invalid path' }); return; }
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post( '/:projectId/rename', ( req, res ) => {
|
||||
const { path: oldPath, newName } = req.body as { path: string; newName: string };
|
||||
if ( !oldPath || !newName ) { res.status( 400 ).json( { error: 'path and newName required' } ); return; }
|
||||
const ok = renameProjectEntry( req.params.projectId, oldPath, newName );
|
||||
if ( !ok ) { res.status( 409 ).json( { error: 'Rename failed' } ); return; }
|
||||
res.json( { ok: true } );
|
||||
} );
|
||||
|
||||
router.post( '/:projectId/delete', ( req, res ) => {
|
||||
const { path: targetPath } = req.body as { path: string };
|
||||
if ( !targetPath ) { res.status( 400 ).json( { error: 'path required' } ); return; }
|
||||
const ok = deleteProjectEntry( req.params.projectId, targetPath );
|
||||
if ( !ok ) { res.status( 409 ).json( { error: 'Delete failed' } ); return; }
|
||||
res.json( { ok: true } );
|
||||
} );
|
||||
|
||||
router.put('/:projectId/*', (req, res) => {
|
||||
const filePath = (req.params as Record<string, string>)[0];
|
||||
if (typeof req.body !== 'string') { res.status(400).json({ error: 'Content must be text' }); return; }
|
||||
const ok = writeProjectFile(req.params.projectId, filePath, req.body);
|
||||
if (!ok) { res.status(403).json({ error: 'Invalid path' }); return; }
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { Router } from 'express';
|
||||
import { groups, groupMembers } from '../db';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
router.get('/', (_req, res) => res.json(groups.all()));
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const { name } = req.body as { name?: string };
|
||||
if (!name) { res.status(400).json({ error: 'Name required' }); return; }
|
||||
try {
|
||||
res.json(groups.create({ name }));
|
||||
} catch {
|
||||
res.status(409).json({ error: 'Group name already taken' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
groups.delete(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/:id/members', (req, res) => {
|
||||
res.json(groupMembers.forGroup(req.params.id));
|
||||
});
|
||||
|
||||
router.post('/:id/members', (req, res) => {
|
||||
const { member_type, member_id } = req.body as { member_type?: 'user' | 'group'; member_id?: string };
|
||||
if (!member_type || !member_id) { res.status(400).json({ error: 'member_type and member_id required' }); return; }
|
||||
res.json(groupMembers.add({ group_id: req.params.id, member_type, member_id }));
|
||||
});
|
||||
|
||||
router.delete('/:id/members/:memberId', (req, res) => {
|
||||
groupMembers.remove(req.params.memberId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { Router } from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { RJLog } from '../../src/library-ts/node/log/RJLog';
|
||||
|
||||
const router = Router();
|
||||
router.use( requireAuth );
|
||||
|
||||
const LAYOUTS_DIR = path.join( __dirname, '..', '..', 'storage', 'layouts' );
|
||||
|
||||
function layoutFilePath( userId: string, deviceId: string ): string
|
||||
{
|
||||
RJLog.log( { userId, deviceId } );
|
||||
const safe = ( s: string ) => s.replace( /[^a-zA-Z0-9_-]/g, '_' );
|
||||
const dir = path.join( LAYOUTS_DIR, safe( userId ) );
|
||||
if ( !fs.existsSync( dir ) ) fs.mkdirSync( dir, { recursive: true } );
|
||||
return path.join( dir, safe( deviceId ) + '.json' );
|
||||
}
|
||||
|
||||
router.get( '/', ( req, res ) =>
|
||||
{
|
||||
const deviceId = req.query.deviceId as string;
|
||||
if ( !deviceId ) { res.json( null ); return; }
|
||||
const fp = layoutFilePath( req.session.userId!, deviceId );
|
||||
if ( !fs.existsSync( fp ) ) { res.json( null ); return; }
|
||||
try { res.json( JSON.parse( fs.readFileSync( fp, 'utf8' ) ) ); }
|
||||
catch { res.json( null ); }
|
||||
} );
|
||||
|
||||
router.put( '/', ( req, res ) =>
|
||||
{
|
||||
const deviceId = req.query.deviceId as string;
|
||||
if ( !deviceId ) { res.status( 400 ).json( { error: 'Missing deviceId' } ); return; }
|
||||
const fp = layoutFilePath( req.session.userId!, deviceId );
|
||||
try
|
||||
{
|
||||
fs.writeFileSync( fp, JSON.stringify( req.body ), 'utf8' );
|
||||
res.json( { ok: true } );
|
||||
}
|
||||
catch ( e ) { res.status( 500 ).json( { error: String( e ) } ); }
|
||||
} );
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { Router } from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const router = Router();
|
||||
const LOCALES_DIR = path.join(__dirname, '..', '..', 'locales');
|
||||
|
||||
router.get('/:locale/*', (req, res) => {
|
||||
const locale = req.params.locale;
|
||||
const filePath = (req.params as Record<string, string>)[0];
|
||||
const abs = path.resolve(path.join(LOCALES_DIR, locale, filePath));
|
||||
if (!abs.startsWith(path.resolve(LOCALES_DIR) + path.sep)) {
|
||||
res.status(403).json({ error: 'Forbidden' });
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(abs)) {
|
||||
res.status(404).json({ error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
const ext = path.extname(abs).slice(1);
|
||||
if (ext === 'html') res.type('text/html').send(fs.readFileSync(abs, 'utf8'));
|
||||
else if (ext === 'json') res.json(JSON.parse(fs.readFileSync(abs, 'utf8')));
|
||||
else res.type('text/plain').send(fs.readFileSync(abs, 'utf8'));
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { Router } from 'express';
|
||||
import { projects, projectMembers } from '../db';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { createProjectStorage } from '../storage';
|
||||
import { RJLog } from '../../src/library-ts/node/log/RJLog';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
router.get('/', (_req, res) => res.json(projects.all()));
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const { name } = req.body as { name?: string };
|
||||
if (!name) { res.status(400).json({ error: 'Name required' }); return; }
|
||||
const project = projects.create({ name, owner_id: req.session.userId! });
|
||||
createProjectStorage(project.id);
|
||||
res.json(project);
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
|
||||
RJLog.log( "Deleting:", req.params.id );
|
||||
projects.delete(req.params.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.get('/:id/members', (req, res) => {
|
||||
res.json(projectMembers.forProject(req.params.id));
|
||||
});
|
||||
|
||||
router.post('/:id/members', (req, res) => {
|
||||
const { member_type, member_id, role } = req.body as { member_type?: 'user' | 'group'; member_id?: string; role?: string };
|
||||
if (!member_type || !member_id) { res.status(400).json({ error: 'member_type and member_id required' }); return; }
|
||||
res.json(projectMembers.add({ project_id: req.params.id, member_type, member_id, role: role ?? 'viewer' }));
|
||||
});
|
||||
|
||||
router.delete('/:id/members/:memberId', (req, res) => {
|
||||
projectMembers.remove(req.params.memberId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import session from 'express-session';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
interface SessionFile
|
||||
{
|
||||
session: session.SessionData;
|
||||
expires: number;
|
||||
}
|
||||
|
||||
export class JsonSessionStore extends session.Store
|
||||
{
|
||||
_dir: string;
|
||||
|
||||
constructor( dir: string )
|
||||
{
|
||||
super();
|
||||
this._dir = dir;
|
||||
if ( ! fs.existsSync( dir ) ) fs.mkdirSync( dir, { recursive: true } );
|
||||
}
|
||||
|
||||
_filePath( sid: string ): string
|
||||
{
|
||||
const safe = sid.replace( /[^a-zA-Z0-9_-]/g, '_' );
|
||||
return path.join( this._dir, safe + '.json' );
|
||||
}
|
||||
|
||||
get( sid: string, callback: ( err: any, session?: session.SessionData ) => void ): void
|
||||
{
|
||||
try
|
||||
{
|
||||
const fp = this._filePath( sid );
|
||||
if ( ! fs.existsSync( fp ) ) { callback( null, null ); return; }
|
||||
const data: SessionFile = JSON.parse( fs.readFileSync( fp, 'utf8' ) );
|
||||
if ( Date.now() > data.expires ) { fs.unlinkSync( fp ); callback( null, null ); return; }
|
||||
callback( null, data.session );
|
||||
}
|
||||
catch ( e ) { callback( e ); }
|
||||
}
|
||||
|
||||
set( sid: string, sessionData: session.SessionData, callback?: ( err?: any ) => void ): void
|
||||
{
|
||||
try
|
||||
{
|
||||
const fp = this._filePath( sid );
|
||||
const expires = sessionData.cookie?.expires
|
||||
? new Date( sessionData.cookie.expires ).getTime()
|
||||
: Date.now() + 7 * 24 * 60 * 60 * 1000;
|
||||
const file: SessionFile = { session: sessionData, expires };
|
||||
fs.writeFileSync( fp, JSON.stringify( file ), 'utf8' );
|
||||
callback?.();
|
||||
}
|
||||
catch ( e ) { callback?.( e ); }
|
||||
}
|
||||
|
||||
destroy( sid: string, callback?: ( err?: any ) => void ): void
|
||||
{
|
||||
try
|
||||
{
|
||||
const fp = this._filePath( sid );
|
||||
if ( fs.existsSync( fp ) ) fs.unlinkSync( fp );
|
||||
callback?.();
|
||||
}
|
||||
catch ( e ) { callback?.( e ); }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const STORAGE_DIR = path.join(__dirname, '..', 'storage');
|
||||
if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR);
|
||||
|
||||
const INITIAL_HTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head><meta charset="UTF-8"><title>New Page</title></head>
|
||||
<body><page-content><h1>Hello World!</h1></page-content></body>
|
||||
</html>`;
|
||||
|
||||
export interface FileNode {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
children?: FileNode[];
|
||||
}
|
||||
|
||||
export function createProjectStorage(projectId: string): void {
|
||||
const rootDir = path.join(STORAGE_DIR, projectId, 'root');
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(rootDir, 'index.html'), INITIAL_HTML);
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
export function getFileTree(projectId: string): FileNode[] {
|
||||
const rootDir = path.join(STORAGE_DIR, projectId, 'root');
|
||||
if (!fs.existsSync(rootDir)) return [];
|
||||
return buildTree(rootDir, rootDir);
|
||||
}
|
||||
|
||||
function safeResolve(projectId: string, filePath: string): string | null {
|
||||
const rootDir = path.resolve(path.join(STORAGE_DIR, projectId, 'root'));
|
||||
const full = path.resolve(path.join(rootDir, filePath));
|
||||
if (!full.startsWith(rootDir + path.sep) && full !== rootDir) return null;
|
||||
return full;
|
||||
}
|
||||
|
||||
export function readProjectFile(projectId: string, filePath: string): string | null {
|
||||
const full = safeResolve(projectId, filePath);
|
||||
if (!full || !fs.existsSync(full)) return null;
|
||||
return fs.readFileSync(full, 'utf8');
|
||||
}
|
||||
|
||||
export function writeProjectFile(projectId: string, filePath: string, content: string): boolean {
|
||||
const full = safeResolve(projectId, filePath);
|
||||
if (!full) return false;
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, content, 'utf8');
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createProjectFile(projectId: string, filePath: string): boolean {
|
||||
const full = safeResolve(projectId, filePath);
|
||||
if (!full || fs.existsSync(full)) return false;
|
||||
fs.mkdirSync(path.dirname(full), { recursive: true });
|
||||
fs.writeFileSync(full, '', 'utf8');
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createProjectDirectory(projectId: string, dirPath: string): boolean {
|
||||
const full = safeResolve(projectId, dirPath);
|
||||
if (!full || fs.existsSync(full)) return false;
|
||||
fs.mkdirSync(full, { recursive: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function projectPathExists(projectId: string, filePath: string): boolean {
|
||||
const full = safeResolve(projectId, filePath);
|
||||
return !!full && fs.existsSync(full);
|
||||
}
|
||||
|
||||
export function renameProjectEntry( projectId: string, oldPath: string, newName: string ): boolean
|
||||
{
|
||||
if ( !oldPath || !newName ) return false;
|
||||
if ( newName.includes( '/' ) || newName.includes( '\\' ) ) return false;
|
||||
const full = safeResolve( projectId, oldPath );
|
||||
if ( !full || !fs.existsSync( full ) ) return false;
|
||||
const rootDir = path.resolve( path.join( STORAGE_DIR, projectId, 'root' ) );
|
||||
const newFull = path.join( path.dirname( full ), newName );
|
||||
if ( !newFull.startsWith( rootDir + path.sep ) ) return false;
|
||||
if ( fs.existsSync( newFull ) ) return false;
|
||||
fs.renameSync( full, newFull );
|
||||
return true;
|
||||
}
|
||||
|
||||
export function deleteProjectEntry( projectId: string, targetPath: string ): boolean
|
||||
{
|
||||
if ( !targetPath ) return false;
|
||||
const full = safeResolve( projectId, targetPath );
|
||||
if ( !full || !fs.existsSync( full ) ) return false;
|
||||
fs.rmSync( full, { recursive: true, force: true } );
|
||||
return true;
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { LocaleManager } from "../locales/LocaleManager";
|
||||
import { Locales } from "../locales/Locales";
|
||||
import { CommandAssignment } from "./CommandAssignments";
|
||||
import { CommandContext } from "./CommandContext";
|
||||
import { CommandManager } from "./CommandManager";
|
||||
|
||||
|
||||
export abstract class Command
|
||||
{
|
||||
_titleLocalePath: string = "";
|
||||
_infoLocalePath: string = "";
|
||||
|
||||
assignments: CommandAssignment[] = [];
|
||||
|
||||
constructor( title: string, info: string)
|
||||
{
|
||||
this._titleLocalePath = title;
|
||||
this._infoLocalePath = info;
|
||||
}
|
||||
|
||||
getLocalizedTitle():Promise<string>
|
||||
{
|
||||
return LocaleManager.$().get( this._titleLocalePath );
|
||||
}
|
||||
|
||||
getLocalizedInfo():Promise<string>
|
||||
{
|
||||
return LocaleManager.$().get( this._infoLocalePath );
|
||||
}
|
||||
|
||||
abstract execute( context: CommandContext ): Promise<void>
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export class CommandAssignment
|
||||
{
|
||||
inputs:string[] = [];
|
||||
conditions:string[] = [];
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export class CommandContext
|
||||
{
|
||||
depth: number;
|
||||
path: string[];
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { CommandContext } from "./CommandContext";
|
||||
import { FileTreeCommands } from "./file-tree-commands/FileTreeCommands";
|
||||
|
||||
export class CommandManager
|
||||
{
|
||||
static fileTreeCommands = new FileTreeCommands();
|
||||
|
||||
|
||||
static createContext(): CommandContext
|
||||
{
|
||||
return { depth: 0, path: [] };
|
||||
}
|
||||
|
||||
static childContext( parent: CommandContext, label: string): CommandContext
|
||||
{
|
||||
return { depth: parent.depth + 1, path: [...parent.path, label] };
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { Command } from "../Command";
|
||||
import { CommandContext } from "../CommandContext";
|
||||
|
||||
export class AddFileCommand extends Command
|
||||
{
|
||||
constructor(){ super( "", "" ); }
|
||||
|
||||
async _doAction( context: CommandContext ): Promise<void>
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
async _undoAction( context: CommandContext ): Promise<void>
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export class FileTreeCommands
|
||||
{
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
interface AuthUser {
|
||||
id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
class AppNav extends HTMLElement {
|
||||
async connectedCallback(): Promise<void> {
|
||||
const res = await fetch('/api/auth/me');
|
||||
if (!res.ok) { location.href = '/login.html'; return; }
|
||||
const user = await res.json() as AuthUser;
|
||||
|
||||
this.innerHTML = `
|
||||
<nav>
|
||||
<span class="nav-brand">Roject</span>
|
||||
<div class="nav-links">
|
||||
<a href="/dashboard.html">Dashboard</a>
|
||||
<a href="/groups.html">Groups</a>
|
||||
<a href="/projects.html">Projects</a>
|
||||
</div>
|
||||
<div class="nav-user">
|
||||
<span>${user.username}</span>
|
||||
<button class="btn-logout">Logout</button>
|
||||
<button class="btn-delete">Delete Account</button>
|
||||
</div>
|
||||
</nav>
|
||||
`;
|
||||
|
||||
this.querySelector('.btn-logout')!.addEventListener('click', async () => {
|
||||
await fetch('/api/auth/logout', { method: 'POST' });
|
||||
location.href = '/login.html';
|
||||
});
|
||||
|
||||
this.querySelector('.btn-delete')!.addEventListener('click', async () => {
|
||||
if (!confirm('Delete your account? This cannot be undone.')) return;
|
||||
await fetch('/api/auth/me', { method: 'DELETE' });
|
||||
location.href = '/login.html';
|
||||
});
|
||||
}
|
||||
}
|
||||
customElements.define('app-nav', AppNav);
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
export interface ConfirmDialogOptions {
|
||||
icon?: string;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
class ConfirmDialog extends HTMLElement {
|
||||
private resolver: ((value: boolean) => void) | null = null;
|
||||
|
||||
connectedCallback(): void {
|
||||
this.style.display = 'none';
|
||||
this.innerHTML = `
|
||||
<div class="cd-backdrop"></div>
|
||||
<div class="cd-box" role="dialog" aria-modal="true">
|
||||
<div class="cd-titlebar">
|
||||
<span class="cd-icon"></span>
|
||||
<span class="cd-title"></span>
|
||||
<button class="cd-close" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="cd-body">
|
||||
<p class="cd-message"></p>
|
||||
</div>
|
||||
<div class="cd-footer"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.querySelector('.cd-backdrop')!.addEventListener('click', () => this.close(false));
|
||||
this.querySelector('.cd-close')!.addEventListener('click', () => this.close(false));
|
||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && this.style.display !== 'none') this.close(false);
|
||||
});
|
||||
}
|
||||
|
||||
show(options: ConfirmDialogOptions): Promise<boolean> {
|
||||
|
||||
console.log( "Show dialog" );
|
||||
|
||||
this.querySelector('.cd-icon')!.textContent = options.icon ?? '';
|
||||
this.querySelector('.cd-title')!.textContent = options.title;
|
||||
this.querySelector('.cd-message')!.textContent = options.message;
|
||||
|
||||
const footer = this.querySelector('.cd-footer')!;
|
||||
footer.innerHTML = '';
|
||||
|
||||
if (options.cancelLabel) {
|
||||
const cancel = document.createElement('button');
|
||||
cancel.className = 'cd-btn cd-btn-cancel';
|
||||
cancel.textContent = options.cancelLabel;
|
||||
cancel.addEventListener('click', () => this.close(false));
|
||||
footer.appendChild(cancel);
|
||||
}
|
||||
|
||||
const confirm = document.createElement('button');
|
||||
confirm.className = `cd-btn cd-btn-confirm${options.danger ? ' cd-btn-danger' : ''}`;
|
||||
confirm.textContent = options.confirmLabel ?? 'OK';
|
||||
confirm.addEventListener('click', () => this.close(true));
|
||||
footer.appendChild(confirm);
|
||||
|
||||
this.style.display = '';
|
||||
confirm.focus();
|
||||
|
||||
return new Promise<boolean>(resolve => { this.resolver = resolve; });
|
||||
}
|
||||
|
||||
private close(value: boolean): void {
|
||||
this.style.display = 'none';
|
||||
console.log( "Closing:", value );
|
||||
if (this.resolver) { this.resolver(value); this.resolver = null; }
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('confirm-dialog', ConfirmDialog);
|
||||
|
||||
export function showConfirmDialog(options: ConfirmDialogOptions): Promise<boolean> {
|
||||
let dialog = document.querySelector('confirm-dialog') as ConfirmDialog | null;
|
||||
if (!dialog) {
|
||||
dialog = document.createElement('confirm-dialog') as ConfirmDialog;
|
||||
document.body.appendChild(dialog);
|
||||
}
|
||||
return dialog.show(options);
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
export abstract class ContextMenuItem
|
||||
{
|
||||
readonly parent: ContextMenuDirectory | null;
|
||||
|
||||
constructor( parent: ContextMenuDirectory | null )
|
||||
{
|
||||
this.parent = parent;
|
||||
}
|
||||
|
||||
abstract createEl(): HTMLElement;
|
||||
}
|
||||
|
||||
export class ContextMenuSeparator extends ContextMenuItem
|
||||
{
|
||||
createEl(): HTMLElement
|
||||
{
|
||||
const el = document.createElement( 'div' );
|
||||
el.className = 'ctx-separator';
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
||||
export class ContextMenuReadOnlyEntry extends ContextMenuItem
|
||||
{
|
||||
label: string;
|
||||
|
||||
constructor( parent: ContextMenuDirectory | null, label: string )
|
||||
{
|
||||
super( parent );
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
createEl(): HTMLElement
|
||||
{
|
||||
const el = document.createElement( 'div' );
|
||||
el.className = 'ctx-readonly';
|
||||
el.textContent = this.label;
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
||||
export class ContextMenuEntry extends ContextMenuItem
|
||||
{
|
||||
label: string;
|
||||
_action: () => void;
|
||||
noClose: boolean;
|
||||
|
||||
constructor( parent: ContextMenuDirectory | null, label: string, action: () => void, noClose = false )
|
||||
{
|
||||
super( parent );
|
||||
this.label = label;
|
||||
this._action = action;
|
||||
this.noClose = noClose;
|
||||
}
|
||||
|
||||
createEl(): HTMLElement
|
||||
{
|
||||
const el = document.createElement( 'div' );
|
||||
el.className = 'ctx-entry';
|
||||
el.textContent = this.label;
|
||||
el.addEventListener( 'click', ( e ) =>
|
||||
{
|
||||
e.stopPropagation();
|
||||
this._action();
|
||||
|
||||
if ( ! this.noClose )
|
||||
{
|
||||
let dir: ContextMenuDirectory = this.parent;
|
||||
while ( dir.parent ) dir = dir.parent;
|
||||
dir.close();
|
||||
}
|
||||
} );
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
||||
export class ContextMenuDirectory extends ContextMenuItem
|
||||
{
|
||||
label: string | null;
|
||||
items: ContextMenuItem[] = [];
|
||||
_menuEl: HTMLElement = null;
|
||||
_closeTimer: number = 0;
|
||||
|
||||
constructor( parent: ContextMenuDirectory | null, label: string | null = null )
|
||||
{
|
||||
super( parent );
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
add( item: ContextMenuItem ): this
|
||||
{
|
||||
this.items.push( item );
|
||||
return this;
|
||||
}
|
||||
|
||||
show( x: number, y: number ): void
|
||||
{
|
||||
this.clearCloseUpwards();
|
||||
|
||||
if ( this._menuEl )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const menu = document.createElement( 'div' );
|
||||
menu.className = 'ctx-menu';
|
||||
menu.style.cssText = 'position:fixed;visibility:hidden';
|
||||
|
||||
for ( const item of this.items )
|
||||
{
|
||||
menu.appendChild( item.createEl() );
|
||||
}
|
||||
|
||||
document.body.appendChild( menu );
|
||||
this._menuEl = menu;
|
||||
|
||||
const w = menu.offsetWidth;
|
||||
const h = menu.offsetHeight;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
|
||||
const left = ( x + w > vw && x - w >= 0 ) ? x - w : x;
|
||||
const top = ( y + h > vh && y - h >= 0 ) ? y - h : y;
|
||||
|
||||
menu.style.left = `${left}px`;
|
||||
menu.style.top = `${top}px`;
|
||||
menu.style.visibility = '';
|
||||
|
||||
menu.addEventListener( 'mouseenter', () => this.clearCloseUpwards() );
|
||||
menu.addEventListener( 'mouseleave', () => this.scheduleClose() );
|
||||
|
||||
if ( ! this.parent )
|
||||
{
|
||||
const onOutside = ( e: MouseEvent ) =>
|
||||
{
|
||||
if ( ! menu.contains( e.target as Node ) )
|
||||
{
|
||||
this.close();
|
||||
document.removeEventListener( 'click', onOutside, { capture: true } );
|
||||
}
|
||||
};
|
||||
setTimeout( () => document.addEventListener( 'click', onOutside, { capture: true } ), 0 );
|
||||
}
|
||||
}
|
||||
|
||||
close(): void
|
||||
{
|
||||
for ( const item of this.items )
|
||||
{
|
||||
if ( item instanceof ContextMenuDirectory ) item.close();
|
||||
}
|
||||
|
||||
this._menuEl?.remove();
|
||||
this._menuEl = null;
|
||||
}
|
||||
|
||||
scheduleClose(): void
|
||||
{
|
||||
this._closeTimer = window.setTimeout( () => this.close(), 150 );
|
||||
}
|
||||
|
||||
clearClose(): void
|
||||
{
|
||||
clearTimeout( this._closeTimer );
|
||||
}
|
||||
|
||||
clearCloseUpwards(): void
|
||||
{
|
||||
let dir: ContextMenuDirectory = this;
|
||||
|
||||
while ( dir )
|
||||
{
|
||||
dir.clearClose();
|
||||
dir = dir.parent;
|
||||
}
|
||||
}
|
||||
|
||||
createEl(): HTMLElement
|
||||
{
|
||||
const el = document.createElement( 'div' );
|
||||
el.className = 'ctx-entry ctx-entry-dir';
|
||||
el.innerHTML = `<span class="ctx-label">${this.label ?? ''}</span><span class="ctx-arrow">▶</span>`;
|
||||
|
||||
el.addEventListener( 'mouseenter', () =>
|
||||
{
|
||||
if ( this.parent )
|
||||
{
|
||||
for ( const item of this.parent.items )
|
||||
{
|
||||
if ( item instanceof ContextMenuDirectory && item !== this ) item.close();
|
||||
}
|
||||
}
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
this.show( rect.right, rect.top );
|
||||
} );
|
||||
|
||||
el.addEventListener( 'mouseleave', () => this.scheduleClose() );
|
||||
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
import { Editor } from '../../editor/Editor.js';
|
||||
|
||||
// ── Layout helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
let tcCounter = 0;
|
||||
function nextTcId(): string { return `tc-${++tcCounter}`; }
|
||||
|
||||
function makeResizeHandle(direction: 'v' | 'h'): HTMLElement {
|
||||
const h = document.createElement('div');
|
||||
h.className = direction === 'v' ? 'es-v-handle' : 'es-h-handle';
|
||||
h.addEventListener('pointerdown', (e: PointerEvent) => {
|
||||
e.preventDefault();
|
||||
h.setPointerCapture(e.pointerId);
|
||||
const prev = h.previousElementSibling as HTMLElement | null;
|
||||
const next = h.nextElementSibling as HTMLElement | null;
|
||||
if (!prev || !next) return;
|
||||
const startPos = direction === 'v' ? e.clientX : e.clientY;
|
||||
const startPrev = direction === 'v' ? prev.offsetWidth : prev.offsetHeight;
|
||||
const startNext = direction === 'v' ? next.offsetWidth : next.offsetHeight;
|
||||
const total = startPrev + startNext;
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const delta = (direction === 'v' ? ev.clientX : ev.clientY) - startPos;
|
||||
const newPrev = Math.max(60, Math.min(total - 60, startPrev + delta));
|
||||
prev.style.flexBasis = `${newPrev}px`;
|
||||
next.style.flexBasis = `${total - newPrev}px`;
|
||||
prev.style.flex = `0 0 ${newPrev}px`;
|
||||
next.style.flex = `0 0 ${total - newPrev}px`;
|
||||
};
|
||||
h.addEventListener('pointermove', onMove);
|
||||
h.addEventListener('pointerup', () => h.removeEventListener('pointermove', onMove), { once: true });
|
||||
});
|
||||
return h;
|
||||
}
|
||||
|
||||
function makeSection(): HTMLElement {
|
||||
const sec = document.createElement('div');
|
||||
sec.className = 'es-section';
|
||||
const tc = document.createElement('tab-container') as HTMLElement;
|
||||
tc.id = nextTcId();
|
||||
sec.appendChild(tc);
|
||||
return sec;
|
||||
}
|
||||
|
||||
function makePanelInner(): HTMLElement {
|
||||
const inner = document.createElement('div');
|
||||
inner.className = 'es-sections';
|
||||
const sec = makeSection();
|
||||
inner.appendChild(sec);
|
||||
return inner;
|
||||
}
|
||||
|
||||
// ── EditorShell ───────────────────────────────────────────────────────────────
|
||||
|
||||
class EditorShell extends HTMLElement {
|
||||
private activePortraitPanel: string = 'center';
|
||||
private _deviceId: string = '';
|
||||
private _saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
async connectedCallback(): Promise<void> {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const projectId = params.get('project') ?? '';
|
||||
const projectName = params.get('name') ?? 'Project';
|
||||
Editor.get().projectId = projectId;
|
||||
Editor.get().projectName = projectName;
|
||||
|
||||
this.innerHTML = `
|
||||
<div class="es-header">
|
||||
<a class="es-back" href="/projects.html">←</a>
|
||||
<span class="es-title">${projectName}</span>
|
||||
<div class="es-portrait-btns">
|
||||
<button class="es-pb-btn" data-panel="left">⊟</button>
|
||||
<button class="es-pb-btn active" data-panel="center">⊡</button>
|
||||
<button class="es-pb-btn" data-panel="right">⊞</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="es-workspace">
|
||||
<div class="es-panel" data-panel="left">${makePanelInner().outerHTML}</div>
|
||||
<div class="es-v-handle"></div>
|
||||
<div class="es-panel" data-panel="center">${makePanelInner().outerHTML}</div>
|
||||
<div class="es-v-handle"></div>
|
||||
<div class="es-panel" data-panel="right">${makePanelInner().outerHTML}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.setupMainHandles();
|
||||
this.setupPortrait();
|
||||
this.setupSplitListener();
|
||||
this.setupResizeHandler();
|
||||
|
||||
await Promise.all( [
|
||||
customElements.whenDefined( 'tab-container' ),
|
||||
customElements.whenDefined( 'file-tree-panel' ),
|
||||
customElements.whenDefined( 'html-editor-panel' ),
|
||||
this._loadLayout(),
|
||||
] );
|
||||
|
||||
this.initDefaultLayout();
|
||||
}
|
||||
|
||||
private initDefaultLayout(): void {
|
||||
const leftTc = this.querySelector('[data-panel="left"] tab-container') as any;
|
||||
const centerTc = this.querySelector('[data-panel="center"] tab-container') as any;
|
||||
|
||||
leftTc?.addTab({ id: 'file-tree', label: 'Files', panelType: 'file-tree' }, () => {
|
||||
return document.createElement('file-tree-panel');
|
||||
});
|
||||
|
||||
centerTc?.addTab({ id: 'html-editor', label: 'Editor', panelType: 'html-editor' }, () => {
|
||||
return document.createElement('html-editor-panel');
|
||||
});
|
||||
|
||||
Editor.get().openDocument('index.html');
|
||||
}
|
||||
|
||||
private setupMainHandles(): void {
|
||||
const workspace = this.querySelector('.es-workspace')!;
|
||||
workspace.querySelectorAll(':scope > .es-v-handle').forEach(h => {
|
||||
const handle = h as HTMLElement;
|
||||
handle.addEventListener('pointerdown', (e: PointerEvent) => {
|
||||
e.preventDefault();
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
const prev = handle.previousElementSibling as HTMLElement;
|
||||
const next = handle.nextElementSibling as HTMLElement;
|
||||
const start = e.clientX;
|
||||
const startPrev = prev.offsetWidth;
|
||||
const startNext = next.offsetWidth;
|
||||
const total = startPrev + startNext;
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
const delta = ev.clientX - start;
|
||||
const np = Math.max(80, Math.min(total - 80, startPrev + delta));
|
||||
prev.style.flex = `0 0 ${np}px`;
|
||||
next.style.flex = `0 0 ${total - np}px`;
|
||||
};
|
||||
handle.addEventListener('pointermove', onMove);
|
||||
handle.addEventListener('pointerup', () => {
|
||||
handle.removeEventListener('pointermove', onMove);
|
||||
this._scheduleLayoutSave();
|
||||
}, { once: true });
|
||||
});
|
||||
});
|
||||
|
||||
this.querySelectorAll('.es-sections').forEach(sections => {
|
||||
this.observeNewHandles(sections as HTMLElement);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private setupSplitListener(): void {
|
||||
this.addEventListener('tab-container:split', (e: Event) => {
|
||||
const { containerId } = (e as CustomEvent).detail as { containerId: string };
|
||||
const tc = document.getElementById(containerId);
|
||||
if (!tc) return;
|
||||
const section = tc.closest('.es-section') as HTMLElement | null;
|
||||
const sections = tc.closest('.es-sections') as HTMLElement | null;
|
||||
if (!section || !sections) return;
|
||||
const newSec = makeSection();
|
||||
const handle = makeResizeHandle('v');
|
||||
sections.insertBefore(handle, section.nextSibling);
|
||||
sections.insertBefore(newSec, handle.nextSibling);
|
||||
});
|
||||
|
||||
this.addEventListener('tab-container:add-panel', (e: Event) => {
|
||||
const { containerId, panelType, tag, label } = (e as CustomEvent).detail as { containerId: string; panelType: string; tag: string; label: string };
|
||||
const tc = document.getElementById(containerId) as any;
|
||||
if (!tc) return;
|
||||
const id = panelType + '-' + Math.random().toString(36).slice(2);
|
||||
tc.addTab({ id, label: label ?? panelType, panelType }, () => document.createElement(tag));
|
||||
});
|
||||
}
|
||||
|
||||
private _getDeviceId(): string
|
||||
{
|
||||
if ( this._deviceId ) return this._deviceId;
|
||||
let id = localStorage.getItem( 'roject:deviceId' );
|
||||
if ( !id )
|
||||
{
|
||||
id = Math.random().toString( 36 ).slice( 2 ) + Math.random().toString( 36 ).slice( 2 );
|
||||
localStorage.setItem( 'roject:deviceId', id );
|
||||
}
|
||||
this._deviceId = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
private async _loadLayout(): Promise<void>
|
||||
{
|
||||
try
|
||||
{
|
||||
const res = await fetch( `/api/layout?deviceId=${this._getDeviceId()}` );
|
||||
if ( !res.ok ) return;
|
||||
const layout = await res.json();
|
||||
if ( !layout ) return;
|
||||
|
||||
if ( layout.panels )
|
||||
{
|
||||
const workspace = this.querySelector( '.es-workspace' ) as HTMLElement;
|
||||
for ( const [ panel, flex ] of Object.entries( layout.panels ) )
|
||||
{
|
||||
if ( flex )
|
||||
{
|
||||
const el = workspace.querySelector( `[data-panel="${panel}"]` ) as HTMLElement;
|
||||
if ( el ) el.style.flex = flex as string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( layout.activePortraitPanel )
|
||||
{
|
||||
this.activePortraitPanel = layout.activePortraitPanel;
|
||||
}
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
private _scheduleLayoutSave(): void
|
||||
{
|
||||
if ( this._saveTimer ) clearTimeout( this._saveTimer );
|
||||
this._saveTimer = setTimeout( () => this._saveLayout(), 800 );
|
||||
}
|
||||
|
||||
private async _saveLayout(): Promise<void>
|
||||
{
|
||||
const workspace = this.querySelector( '.es-workspace' ) as HTMLElement;
|
||||
const panels: Record<string, string | null> = {};
|
||||
for ( const p of [ 'left', 'center', 'right' ] )
|
||||
{
|
||||
const el = workspace.querySelector( `[data-panel="${p}"]` ) as HTMLElement;
|
||||
panels[ p ] = el?.style.flex || null;
|
||||
}
|
||||
const layout = { panels, activePortraitPanel: this.activePortraitPanel };
|
||||
try
|
||||
{
|
||||
await fetch( `/api/layout?deviceId=${this._getDeviceId()}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify( layout ),
|
||||
} );
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
private setupResizeHandler(): void
|
||||
{
|
||||
const workspace = this.querySelector( '.es-workspace' ) as HTMLElement;
|
||||
const obs = new ResizeObserver( () =>
|
||||
{
|
||||
this._redistributeFlex( workspace, '.es-panel' );
|
||||
workspace.querySelectorAll( '.es-sections' ).forEach( container =>
|
||||
{
|
||||
this._redistributeFlex( container as HTMLElement, '.es-section' );
|
||||
} );
|
||||
} );
|
||||
obs.observe( workspace );
|
||||
}
|
||||
|
||||
private _redistributeFlex( container: HTMLElement, childSelector: string ): void
|
||||
{
|
||||
const children = Array.from( container.querySelectorAll( `:scope > ${childSelector}` ) ) as HTMLElement[];
|
||||
if ( children.length < 2 ) return;
|
||||
if ( ! children.some( c => c.style.flex ) ) return;
|
||||
|
||||
const totalPanel = children.reduce( ( sum, c ) => sum + c.offsetWidth, 0 );
|
||||
if ( totalPanel === 0 ) return;
|
||||
|
||||
const handles = Array.from( container.children ).filter(
|
||||
c => ! ( c as HTMLElement ).matches( childSelector )
|
||||
) as HTMLElement[];
|
||||
const handleTotal = handles.reduce( ( sum, h ) => sum + ( h as HTMLElement ).offsetWidth, 0 );
|
||||
const available = container.clientWidth - handleTotal;
|
||||
|
||||
children.forEach( c =>
|
||||
{
|
||||
const ratio = c.offsetWidth / totalPanel;
|
||||
c.style.flex = `0 0 ${Math.round( ratio * available )}px`;
|
||||
} );
|
||||
}
|
||||
|
||||
private observeNewHandles(sections: HTMLElement): void {
|
||||
const observer = new MutationObserver(() => {
|
||||
sections.querySelectorAll('.es-h-handle:not([data-bound])').forEach(h => {
|
||||
(h as HTMLElement).dataset.bound = '1';
|
||||
});
|
||||
});
|
||||
observer.observe(sections, { childList: true });
|
||||
}
|
||||
|
||||
private setupPortrait(): void {
|
||||
const btns = this.querySelector( '.es-portrait-btns' ) as HTMLElement;
|
||||
const mq = window.matchMedia( '(orientation: portrait)' );
|
||||
|
||||
const apply = ( portrait: boolean ) => {
|
||||
this.classList.toggle( 'portrait', portrait );
|
||||
if ( portrait ) this.showPortraitPanel( this.activePortraitPanel );
|
||||
};
|
||||
|
||||
btns.querySelectorAll( '.es-pb-btn' ).forEach( btn => {
|
||||
btn.addEventListener( 'click', () => {
|
||||
const panel = ( btn as HTMLElement ).dataset.panel!;
|
||||
this.activePortraitPanel = panel;
|
||||
btns.querySelectorAll( '.es-pb-btn' ).forEach( b => b.classList.remove( 'active' ) );
|
||||
btn.classList.add( 'active' );
|
||||
this.showPortraitPanel( panel );
|
||||
this._scheduleLayoutSave();
|
||||
} );
|
||||
} );
|
||||
|
||||
mq.addEventListener( 'change', e => apply( e.matches ) );
|
||||
apply( mq.matches );
|
||||
}
|
||||
|
||||
private showPortraitPanel(panelId: string): void {
|
||||
this.querySelectorAll('.es-panel').forEach(p => {
|
||||
(p as HTMLElement).style.display = (p as HTMLElement).dataset.panel === panelId ? '' : 'none';
|
||||
});
|
||||
this.querySelectorAll('.es-v-handle').forEach(h => { (h as HTMLElement).style.display = 'none'; });
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('editor-shell', EditorShell);
|
||||
|
|
@ -0,0 +1,378 @@
|
|||
import { Editor } from '../../editor/Editor.js';
|
||||
import { ContextMenuDirectory, ContextMenuEntry, ContextMenuReadOnlyEntry, ContextMenuSeparator } from '../context-menu/context-menu.js';
|
||||
import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js';
|
||||
|
||||
interface FileNode {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
children?: FileNode[];
|
||||
}
|
||||
|
||||
class FileTreePanel extends HTMLElement {
|
||||
selectedPath: string | null = null;
|
||||
_rootPath: string = '';
|
||||
_initialized = false;
|
||||
|
||||
async connectedCallback(): Promise<void> {
|
||||
if ( this._initialized ) return;
|
||||
this._initialized = true;
|
||||
|
||||
this.className = 'file-tree-panel';
|
||||
this.innerHTML = `
|
||||
<div class="ftp-header">
|
||||
<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>
|
||||
</div>
|
||||
<div class="ftp-tree">Loading…</div>
|
||||
`;
|
||||
|
||||
this.querySelector( '[data-action="add-file"]' )!.addEventListener( 'click', () => this.addFile() );
|
||||
this.querySelector( '[data-action="add-dir"]' )!.addEventListener( 'click', () => this.addDirectory() );
|
||||
|
||||
Editor.get().onFilesChanged.addListener( () => this.refresh() );
|
||||
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
addContextMenuEntries( dir: ContextMenuDirectory ): void {
|
||||
dir.add( new ContextMenuReadOnlyEntry( dir, 'File Tree' ) );
|
||||
}
|
||||
|
||||
_updateTabLabel(): void {
|
||||
const label = '📁 ' + this._dirnameDisplay();
|
||||
this.dispatchEvent( new CustomEvent( 'panel:label-change', { bubbles: true, detail: { label } } ) );
|
||||
}
|
||||
|
||||
_dirnameDisplay(): string {
|
||||
if ( !this._rootPath ) return '/';
|
||||
const lastSlash = this._rootPath.lastIndexOf( '/' );
|
||||
return lastSlash === -1 ? this._rootPath : this._rootPath.slice( lastSlash + 1 );
|
||||
}
|
||||
|
||||
_findNode( nodes: FileNode[], targetPath: string ): FileNode | null {
|
||||
for ( const n of nodes ) {
|
||||
if ( n.path === targetPath ) return n;
|
||||
if ( n.children ) {
|
||||
const found = this._findNode( n.children, targetPath );
|
||||
if ( found ) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_goUp(): void {
|
||||
const lastSlash = this._rootPath.lastIndexOf( '/' );
|
||||
this._rootPath = lastSlash === -1 ? '' : this._rootPath.slice( 0, lastSlash );
|
||||
this.selectedPath = null;
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
const state = Editor.get();
|
||||
const res = await fetch( `/api/files/${state.projectId}/tree` );
|
||||
const allNodes = await res.json() as FileNode[];
|
||||
const tree = this.querySelector( '.ftp-tree' )!;
|
||||
|
||||
let nodes: FileNode[];
|
||||
|
||||
if ( this._rootPath ) {
|
||||
const rootNode = this._findNode( allNodes, this._rootPath );
|
||||
nodes = rootNode ? ( rootNode.children ?? [] ) : [];
|
||||
}
|
||||
else {
|
||||
nodes = allNodes;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
|
||||
if ( this._rootPath ) {
|
||||
html += `<div class="ftp-up" data-action="go-up">[ .. ]</div>`;
|
||||
}
|
||||
|
||||
html += nodes.length ? this.renderNodes( nodes ) : '<span class="ftp-empty">Empty</span>';
|
||||
tree.innerHTML = html;
|
||||
|
||||
const upBtn = tree.querySelector( '[data-action="go-up"]' );
|
||||
if ( upBtn ) {
|
||||
upBtn.addEventListener( 'click', () => this._goUp() );
|
||||
}
|
||||
|
||||
this._updateTabLabel();
|
||||
this.bindTree( tree );
|
||||
}
|
||||
|
||||
bindTree( tree: Element ): void {
|
||||
const state = Editor.get();
|
||||
tree.querySelectorAll( '.ftp-file' ).forEach( el => {
|
||||
el.addEventListener( 'click', () => {
|
||||
const path = ( el as HTMLElement ).dataset.path!;
|
||||
this.selectedPath = path;
|
||||
tree.querySelectorAll( '.ftp-file, .ftp-dir-label' ).forEach( f => f.classList.remove( 'active' ) );
|
||||
el.classList.add( 'active' );
|
||||
state.openDocument( path );
|
||||
} );
|
||||
el.addEventListener( 'contextmenu', ( e: Event ) => {
|
||||
e.preventDefault();
|
||||
const me = e as MouseEvent;
|
||||
this.showItemMenu( ( el as HTMLElement ).dataset.path!, me.clientX, me.clientY );
|
||||
} );
|
||||
} );
|
||||
tree.querySelectorAll( '.ftp-dir-label' ).forEach( el => {
|
||||
el.addEventListener( 'click', () => {
|
||||
const li = el.closest( 'li' )!;
|
||||
li.classList.toggle( 'open' );
|
||||
const path = ( el as HTMLElement ).dataset.path!;
|
||||
this.selectedPath = path;
|
||||
tree.querySelectorAll( '.ftp-file, .ftp-dir-label' ).forEach( f => f.classList.remove( 'active' ) );
|
||||
el.classList.add( 'active' );
|
||||
} );
|
||||
el.addEventListener( 'dblclick', () => {
|
||||
const path = ( el as HTMLElement ).dataset.path!;
|
||||
this._rootPath = path;
|
||||
this.selectedPath = null;
|
||||
this.refresh();
|
||||
} );
|
||||
el.addEventListener( 'contextmenu', ( e: Event ) => {
|
||||
e.preventDefault();
|
||||
const me = e as MouseEvent;
|
||||
this.showItemMenu( ( el as HTMLElement ).dataset.path!, me.clientX, me.clientY );
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
showItemMenu( targetPath: string, x: number, y: number ): void
|
||||
{
|
||||
this.selectedPath = targetPath;
|
||||
const tree = this.querySelector( '.ftp-tree' )!;
|
||||
tree.querySelectorAll( '.ftp-file, .ftp-dir-label' ).forEach( f => f.classList.remove( 'active' ) );
|
||||
tree.querySelector( `[data-path="${CSS.escape( targetPath )}"]` )?.classList.add( 'active' );
|
||||
|
||||
const menu = new ContextMenuDirectory( null );
|
||||
menu.add( new ContextMenuReadOnlyEntry( menu, targetPath ) );
|
||||
menu.add( new ContextMenuSeparator( menu ) );
|
||||
menu.add( new ContextMenuEntry( menu, 'Rename…', () => this.startInlineRename( targetPath ) ) );
|
||||
menu.add( new ContextMenuEntry( menu, 'Delete', () => this.deleteEntry( targetPath ) ) );
|
||||
menu.show( x, y );
|
||||
}
|
||||
|
||||
startInlineRename( oldPath: string ): void
|
||||
{
|
||||
const tree = this.querySelector( '.ftp-tree' )!;
|
||||
const lastSlash = oldPath.lastIndexOf( '/' );
|
||||
const currentName = lastSlash === -1 ? oldPath : oldPath.slice( lastSlash + 1 );
|
||||
|
||||
const overlay = document.createElement( 'div' );
|
||||
overlay.className = 'ftp-inline-create';
|
||||
|
||||
const input = document.createElement( 'input' );
|
||||
input.className = 'ftp-rename-input';
|
||||
input.value = currentName;
|
||||
input.type = 'text';
|
||||
|
||||
overlay.innerHTML = `<span class="ftp-inline-label">✎ Rename: ${oldPath}</span>`;
|
||||
overlay.appendChild( input );
|
||||
|
||||
const btnRename = document.createElement( 'button' );
|
||||
btnRename.textContent = 'Rename';
|
||||
btnRename.className = 'ftp-btn';
|
||||
const btnCancel = document.createElement( 'button' );
|
||||
btnCancel.textContent = 'Cancel';
|
||||
btnCancel.className = 'ftp-btn';
|
||||
|
||||
overlay.appendChild( btnRename );
|
||||
overlay.appendChild( btnCancel );
|
||||
tree.prepend( overlay );
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
const confirm = async () =>
|
||||
{
|
||||
const newName = input.value.trim();
|
||||
if ( !newName || newName === currentName ) { overlay.remove(); return; }
|
||||
await this.renameEntry( oldPath, newName );
|
||||
overlay.remove();
|
||||
};
|
||||
|
||||
const cancel = () => overlay.remove();
|
||||
|
||||
btnRename.addEventListener( 'click', confirm );
|
||||
btnCancel.addEventListener( 'click', cancel );
|
||||
input.addEventListener( 'keydown', ( e: KeyboardEvent ) =>
|
||||
{
|
||||
if ( 'Enter' === e.key ) confirm();
|
||||
if ( 'Escape' === e.key ) cancel();
|
||||
} );
|
||||
}
|
||||
|
||||
async renameEntry( oldPath: string, newName: string ): Promise<void>
|
||||
{
|
||||
const state = Editor.get();
|
||||
const res = await fetch( `/api/files/${state.projectId}/rename`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify( { path: oldPath, newName } ),
|
||||
} );
|
||||
|
||||
if ( !res.ok ) return;
|
||||
|
||||
const lastSlash = oldPath.lastIndexOf( '/' );
|
||||
const parent = lastSlash === -1 ? '' : oldPath.slice( 0, lastSlash );
|
||||
this.selectedPath = parent ? `${parent}/${newName}` : newName;
|
||||
Editor.get().onFilesChanged.dispatch();
|
||||
}
|
||||
|
||||
async deleteEntry( targetPath: string ): Promise<void>
|
||||
{
|
||||
const lastSlash = targetPath.lastIndexOf( '/' );
|
||||
const name = lastSlash === -1 ? targetPath : targetPath.slice( lastSlash + 1 );
|
||||
const confirmed = await showConfirmDialog( {
|
||||
icon: '🗑',
|
||||
title: 'Delete',
|
||||
message: `Delete "${name}"? This cannot be undone.`,
|
||||
confirmLabel: 'Delete',
|
||||
cancelLabel: 'Cancel',
|
||||
danger: true,
|
||||
} );
|
||||
|
||||
if ( !confirmed ) return;
|
||||
|
||||
const state = Editor.get();
|
||||
const res = await fetch( `/api/files/${state.projectId}/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify( { path: targetPath } ),
|
||||
} );
|
||||
|
||||
if ( !res.ok ) return;
|
||||
|
||||
if ( this.selectedPath === targetPath ) this.selectedPath = null;
|
||||
Editor.get().onFilesChanged.dispatch();
|
||||
}
|
||||
|
||||
resolveTargetDir(): string {
|
||||
if ( !this.selectedPath ) return this._rootPath;
|
||||
|
||||
const tree = this.querySelector( '.ftp-tree' )!;
|
||||
const dirLabel = tree.querySelector( `.ftp-dir-label[data-path="${CSS.escape( this.selectedPath )}"]` );
|
||||
if ( dirLabel ) return this.selectedPath;
|
||||
|
||||
const lastSlash = this.selectedPath.lastIndexOf( '/' );
|
||||
return lastSlash === -1 ? this._rootPath : this.selectedPath.slice( 0, lastSlash );
|
||||
}
|
||||
|
||||
async addFile(): Promise<void> {
|
||||
const state = Editor.get();
|
||||
const dir = this.resolveTargetDir();
|
||||
const name = await this.findFreeName(state.projectId, dir, 'file', 'txt');
|
||||
const filePath = dir ? `${dir}/${name}` : name;
|
||||
this.startInlineCreate(filePath, 'file', name);
|
||||
}
|
||||
|
||||
async addDirectory(): Promise<void> {
|
||||
const state = Editor.get();
|
||||
const dir = this.resolveTargetDir();
|
||||
const name = await this.findFreeName(state.projectId, dir, 'directory', '');
|
||||
const dirPath = dir ? `${dir}/${name}` : name;
|
||||
this.startInlineCreate(dirPath, 'directory', name);
|
||||
}
|
||||
|
||||
async findFreeName(projectId: string, dir: string, type: 'file' | 'directory', ext: string): Promise<string> {
|
||||
const baseName = type === 'file' ? 'file' : 'directory';
|
||||
const res = await fetch(`/api/files/${projectId}/tree`);
|
||||
const tree = await res.json() as FileNode[];
|
||||
for (let i = 1; i <= 999; i++) {
|
||||
const name = ext ? `${baseName}${i > 1 ? i : ''}.${ext}` : `${baseName}${i > 1 ? i : ''}`;
|
||||
const testPath = dir ? `${dir}/${name}` : name;
|
||||
if (!this.pathExistsInTree(tree, testPath)) return name;
|
||||
}
|
||||
return `${baseName}-${Date.now()}${ext ? '.' + ext : ''}`;
|
||||
}
|
||||
|
||||
pathExistsInTree(nodes: FileNode[], targetPath: string): boolean {
|
||||
for (const n of nodes) {
|
||||
if (n.path === targetPath) return true;
|
||||
if (n.children && this.pathExistsInTree(n.children, targetPath)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
startInlineCreate(fullPath: string, type: 'file' | 'directory', defaultName: string): void {
|
||||
const tree = this.querySelector('.ftp-tree')!;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'ftp-inline-create';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.className = 'ftp-rename-input';
|
||||
input.value = defaultName;
|
||||
input.type = 'text';
|
||||
|
||||
overlay.innerHTML = `<span class="ftp-inline-label">${type === 'file' ? '📄' : '📁'} New ${type} in: ${fullPath.includes('/') ? fullPath.slice(0, fullPath.lastIndexOf('/')) || '/' : '/'}</span>`;
|
||||
overlay.appendChild(input);
|
||||
|
||||
const btnCreate = document.createElement('button');
|
||||
btnCreate.textContent = 'Create';
|
||||
btnCreate.className = 'ftp-btn';
|
||||
const btnCancel = document.createElement('button');
|
||||
btnCancel.textContent = 'Cancel';
|
||||
btnCancel.className = 'ftp-btn';
|
||||
|
||||
overlay.appendChild(btnCreate);
|
||||
overlay.appendChild(btnCancel);
|
||||
tree.prepend(overlay);
|
||||
input.focus();
|
||||
input.select();
|
||||
|
||||
const confirm = async () => {
|
||||
const name = input.value.trim();
|
||||
if (!name) return;
|
||||
const dir = fullPath.includes('/') ? fullPath.slice(0, fullPath.lastIndexOf('/')) : '';
|
||||
const finalPath = dir ? `${dir}/${name}` : name;
|
||||
await this.createEntry(type, finalPath);
|
||||
overlay.remove();
|
||||
};
|
||||
|
||||
const cancel = () => overlay.remove();
|
||||
|
||||
btnCreate.addEventListener('click', confirm);
|
||||
btnCancel.addEventListener('click', cancel);
|
||||
input.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') confirm();
|
||||
if (e.key === 'Escape') cancel();
|
||||
});
|
||||
}
|
||||
|
||||
async createEntry(type: 'file' | 'directory', path: string): Promise<void> {
|
||||
const state = Editor.get();
|
||||
const endpoint = type === 'file' ? 'create-file' : 'create-directory';
|
||||
const res = await fetch(`/api/files/${state.projectId}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
if ( res.ok )
|
||||
{
|
||||
if ( type === 'file' )
|
||||
{
|
||||
this.selectedPath = path;
|
||||
}
|
||||
|
||||
Editor.get().onFilesChanged.dispatch();
|
||||
}
|
||||
}
|
||||
|
||||
renderNodes(nodes: FileNode[], depth = 0): string {
|
||||
return `<ul class="ftp-list" style="--depth:${depth}">` + nodes.map(n => {
|
||||
if (n.type === 'directory') {
|
||||
return `<li class="ftp-dir open">
|
||||
<span class="ftp-dir-label" data-path="${n.path}">▸ ${n.name}</span>
|
||||
${this.renderNodes(n.children ?? [], depth + 1)}
|
||||
</li>`;
|
||||
}
|
||||
const isHtml = n.name.endsWith('.html');
|
||||
return `<li class="ftp-file${isHtml ? ' ftp-html' : ''}" data-path="${n.path}">${n.name}</li>`;
|
||||
}).join('') + '</ul>';
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('file-tree-panel', FileTreePanel);
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface GroupMember {
|
||||
id: string;
|
||||
group_id: string;
|
||||
member_type: 'user' | 'group';
|
||||
member_id: string;
|
||||
}
|
||||
|
||||
class GroupEditor extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.render();
|
||||
}
|
||||
|
||||
async render(): Promise<void> {
|
||||
const res = await fetch('/api/groups');
|
||||
const groups = await res.json() as Group[];
|
||||
|
||||
this.innerHTML = `
|
||||
<div class="group-editor">
|
||||
<h2>Groups</h2>
|
||||
<form class="create-form">
|
||||
<input name="name" placeholder="New group name" required>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
<ul class="group-list">
|
||||
${groups.map(g => `
|
||||
<li data-id="${g.id}">
|
||||
<div class="group-header">
|
||||
<strong>${g.name}</strong>
|
||||
<button class="btn-members" data-id="${g.id}">Members</button>
|
||||
<button class="btn-delete" data-id="${g.id}">Delete</button>
|
||||
</div>
|
||||
<div class="members-panel" data-group="${g.id}" style="display:none"></div>
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.querySelector('.create-form')!.addEventListener('submit', async (e: Event) => {
|
||||
e.preventDefault();
|
||||
const form = e.target as HTMLFormElement;
|
||||
const name = (form.elements.namedItem('name') as HTMLInputElement).value.trim();
|
||||
const res = await fetch('/api/groups', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
if (res.ok) this.render();
|
||||
});
|
||||
|
||||
this.querySelectorAll('.btn-delete').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm('Delete group?')) return;
|
||||
await fetch(`/api/groups/${(btn as HTMLElement).dataset.id}`, { method: 'DELETE' });
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
|
||||
this.querySelectorAll('.btn-members').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.toggleMembers((btn as HTMLElement).dataset.id!));
|
||||
});
|
||||
}
|
||||
|
||||
async toggleMembers(groupId: string): Promise<void> {
|
||||
const panel = this.querySelector(`.members-panel[data-group="${groupId}"]`) as HTMLElement;
|
||||
if (panel.style.display !== 'none') { panel.style.display = 'none'; return; }
|
||||
panel.style.display = 'block';
|
||||
await this.loadMembers(groupId, panel);
|
||||
}
|
||||
|
||||
async loadMembers(groupId: string, panel: HTMLElement): Promise<void> {
|
||||
const res = await fetch(`/api/groups/${groupId}/members`);
|
||||
const members = await res.json() as GroupMember[];
|
||||
|
||||
panel.innerHTML = `
|
||||
<ul class="member-list">
|
||||
${members.map(m => `
|
||||
<li>${m.member_type}: ${m.member_id}
|
||||
<button class="btn-remove-member" data-gid="${groupId}" data-mid="${m.id}">Remove</button>
|
||||
</li>
|
||||
`).join('') || '<li class="empty">No members</li>'}
|
||||
</ul>
|
||||
<form class="add-member-form">
|
||||
<select name="member_type">
|
||||
<option value="user">User ID</option>
|
||||
<option value="group">Group ID</option>
|
||||
</select>
|
||||
<input name="member_id" type="number" placeholder="ID" required>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
`;
|
||||
|
||||
panel.querySelectorAll('.btn-remove-member').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const el = btn as HTMLElement;
|
||||
await fetch(`/api/groups/${el.dataset.gid}/members/${el.dataset.mid}`, { method: 'DELETE' });
|
||||
await this.loadMembers(groupId, panel);
|
||||
});
|
||||
});
|
||||
|
||||
(panel.querySelector('.add-member-form') as HTMLFormElement).addEventListener('submit', async (e: Event) => {
|
||||
e.preventDefault();
|
||||
const data = Object.fromEntries(new FormData(e.target as HTMLFormElement));
|
||||
await fetch(`/api/groups/${groupId}/members`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
await this.loadMembers(groupId, panel);
|
||||
});
|
||||
}
|
||||
}
|
||||
customElements.define('group-editor', GroupEditor);
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
import { Editor } from '../../editor/Editor.js';
|
||||
import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js';
|
||||
|
||||
class HtmlEditorPanel extends HTMLElement
|
||||
{
|
||||
currentPath: string | null = null;
|
||||
_pinned: boolean = false;
|
||||
_iframe: HTMLIFrameElement | null = null;
|
||||
_undoStack: string[] = [];
|
||||
_redoStack: string[] = [];
|
||||
_mutationObserver: MutationObserver | null = null;
|
||||
_initialized = false;
|
||||
_needsRestore = false;
|
||||
|
||||
connectedCallback(): void
|
||||
{
|
||||
if ( this._initialized ) return;
|
||||
this._initialized = true;
|
||||
|
||||
this.className = 'html-editor-panel';
|
||||
this.innerHTML = `
|
||||
<div class="hep-toolbar">
|
||||
<button class="hep-pin" title="Pin — keep this file when selecting others">Pin</button>
|
||||
<button class="hep-undo" title="Undo (Ctrl+Z)" disabled>↩</button>
|
||||
<button class="hep-redo" title="Redo (Ctrl+Y)" disabled>↪</button>
|
||||
<button class="hep-save" title="Save (Ctrl+S)" disabled>Save</button>
|
||||
<button class="hep-init" title="Insert Hello World template" disabled>Init</button>
|
||||
</div>
|
||||
<div class="hep-empty">Open an HTML file from the file tree</div>
|
||||
<iframe class="hep-frame" style="display:none"></iframe>
|
||||
`;
|
||||
|
||||
this._iframe = this.querySelector( 'iframe' );
|
||||
|
||||
this.querySelector( '.hep-pin' )!.addEventListener( 'click', () => this._togglePin() );
|
||||
this.querySelector( '.hep-save' )!.addEventListener( 'click', () => this._save() );
|
||||
this.querySelector( '.hep-undo' )!.addEventListener( 'click', () => this._undo() );
|
||||
this.querySelector( '.hep-redo' )!.addEventListener( 'click', () => this._redo() );
|
||||
this.querySelector( '.hep-init' )!.addEventListener( 'click', () => this._initTemplate() );
|
||||
|
||||
Editor.get().onDocumentOpened.addListener( ( e ) =>
|
||||
{
|
||||
if ( ! this._pinned ) this._loadDocument( e.path, e.content );
|
||||
} );
|
||||
|
||||
document.addEventListener( 'keydown', ( e: KeyboardEvent ) =>
|
||||
{
|
||||
if ( ! this.currentPath )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ( e.ctrlKey && e.key === 's' ) { e.preventDefault(); this._save(); }
|
||||
if ( e.ctrlKey && ! e.shiftKey && e.key === 'z' ) { e.preventDefault(); this._undo(); }
|
||||
if ( e.ctrlKey && ( e.key === 'y' || ( e.shiftKey && e.key === 'z' ) ) ) { e.preventDefault(); this._redo(); }
|
||||
} );
|
||||
}
|
||||
|
||||
disconnectedCallback(): void
|
||||
{
|
||||
if ( this._undoStack.length > 0 )
|
||||
{
|
||||
this._needsRestore = true;
|
||||
}
|
||||
}
|
||||
|
||||
_updateTabLabel( path: string ): void
|
||||
{
|
||||
const name = path ? path.slice( path.lastIndexOf( '/' ) + 1 ) : '';
|
||||
this.dispatchEvent( new CustomEvent( 'panel:label-change', { bubbles: true, detail: { label: '📄 ' + name } } ) );
|
||||
}
|
||||
|
||||
_loadDocument( path: string, content: string ): void
|
||||
{
|
||||
this.currentPath = path;
|
||||
this._undoStack = [ content ];
|
||||
this._redoStack = [];
|
||||
this._updateTabLabel( path );
|
||||
this.querySelector( '.hep-empty' )!.setAttribute( 'style', 'display:none' );
|
||||
this._iframe!.style.display = '';
|
||||
this._renderContent( content );
|
||||
this._updateButtons( false );
|
||||
}
|
||||
|
||||
_renderContent( html: string ): void
|
||||
{
|
||||
const iframe = this._iframe!;
|
||||
|
||||
if ( this._mutationObserver )
|
||||
{
|
||||
this._mutationObserver.disconnect();
|
||||
this._mutationObserver = null;
|
||||
}
|
||||
|
||||
iframe.srcdoc = html;
|
||||
iframe.onload = () =>
|
||||
{
|
||||
if ( this._needsRestore )
|
||||
{
|
||||
this._needsRestore = false;
|
||||
this._renderContent( this._undoStack[ this._undoStack.length - 1 ] );
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = iframe.contentDocument!;
|
||||
const pc = doc.querySelector( 'page-content' );
|
||||
|
||||
if ( pc )
|
||||
{
|
||||
( pc as HTMLElement ).contentEditable = 'true';
|
||||
( pc as HTMLElement ).style.outline = 'none';
|
||||
this._mutationObserver = new MutationObserver( () => this._onContentChanged() );
|
||||
this._mutationObserver.observe( pc, { subtree: true, childList: true, characterData: true, attributes: true } );
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
_onContentChanged(): void
|
||||
{
|
||||
if ( ! this.currentPath || ! this._iframe?.contentDocument )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const html = '<!DOCTYPE html>\n' + this._iframe.contentDocument.documentElement.outerHTML;
|
||||
const last = this._undoStack[ this._undoStack.length - 1 ];
|
||||
|
||||
if ( html === last )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._undoStack.push( html );
|
||||
|
||||
if ( this._undoStack.length > 200 )
|
||||
{
|
||||
this._undoStack.shift();
|
||||
}
|
||||
|
||||
this._redoStack = [];
|
||||
Editor.get().markDirty( this.currentPath, html );
|
||||
this._updateButtons( true );
|
||||
}
|
||||
|
||||
_undo(): void
|
||||
{
|
||||
if ( this._undoStack.length < 2 )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const current = this._undoStack.pop()!;
|
||||
this._redoStack.push( current );
|
||||
const prev = this._undoStack[ this._undoStack.length - 1 ];
|
||||
Editor.get().markDirty( this.currentPath!, prev );
|
||||
this._renderContent( prev );
|
||||
this._updateButtons( true );
|
||||
}
|
||||
|
||||
_redo(): void
|
||||
{
|
||||
if ( ! this._redoStack.length )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const next = this._redoStack.pop()!;
|
||||
this._undoStack.push( next );
|
||||
Editor.get().markDirty( this.currentPath!, next );
|
||||
this._renderContent( next );
|
||||
this._updateButtons( true );
|
||||
}
|
||||
|
||||
_togglePin(): void
|
||||
{
|
||||
this._pinned = ! this._pinned;
|
||||
this.querySelector( '.hep-pin' )!.classList.toggle( 'pinned', this._pinned );
|
||||
}
|
||||
|
||||
_initTemplate(): void
|
||||
{
|
||||
if ( ! this.currentPath ) return;
|
||||
|
||||
const template = [
|
||||
'<!DOCTYPE html>',
|
||||
'<html lang="en">',
|
||||
'<head>',
|
||||
' <meta charset="UTF-8">',
|
||||
' <title>Hello World</title>',
|
||||
'</head>',
|
||||
'<body>',
|
||||
' <page-content>',
|
||||
' <h1>Hello World</h1>',
|
||||
' <p>Welcome.</p>',
|
||||
' </page-content>',
|
||||
'</body>',
|
||||
'</html>',
|
||||
].join( '\n' );
|
||||
|
||||
this._undoStack.push( template );
|
||||
this._redoStack = [];
|
||||
Editor.get().markDirty( this.currentPath, template );
|
||||
this._renderContent( template );
|
||||
this._updateButtons( true );
|
||||
}
|
||||
|
||||
async _save(): Promise<void>
|
||||
{
|
||||
if ( ! this.currentPath )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Editor.get().save( this.currentPath );
|
||||
this._updateButtons( false );
|
||||
}
|
||||
|
||||
addContextMenuEntries( dir: ContextMenuDirectory ): void
|
||||
{
|
||||
if ( this.currentPath )
|
||||
{
|
||||
dir.add( new ContextMenuReadOnlyEntry( dir, `Editing: ${this.currentPath}` ) );
|
||||
}
|
||||
else
|
||||
{
|
||||
dir.add( new ContextMenuReadOnlyEntry( dir, 'No document open' ) );
|
||||
}
|
||||
}
|
||||
|
||||
_updateButtons( dirty: boolean ): void
|
||||
{
|
||||
( this.querySelector( '.hep-save' ) as HTMLButtonElement ).disabled = ! dirty;
|
||||
( this.querySelector( '.hep-undo' ) as HTMLButtonElement ).disabled = this._undoStack.length < 2;
|
||||
( this.querySelector( '.hep-redo' ) as HTMLButtonElement ).disabled = this._redoStack.length === 0;
|
||||
( this.querySelector( '.hep-init' ) as HTMLButtonElement ).disabled = ! this.currentPath;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define( 'html-editor-panel', HtmlEditorPanel );
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
class LoginForm extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.innerHTML = `
|
||||
<form>
|
||||
<h2>Login</h2>
|
||||
<p class="error"></p>
|
||||
<label>Username<input name="username" autocomplete="username" required></label>
|
||||
<label>Password<input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button type="submit">Login</button>
|
||||
<a href="/register.html">Create account</a>
|
||||
</form>
|
||||
`;
|
||||
this.querySelector('form')!.addEventListener('submit', async (e: Event) => {
|
||||
e.preventDefault();
|
||||
const form = e.target as HTMLFormElement;
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (res.ok) {
|
||||
location.href = '/dashboard.html';
|
||||
} else {
|
||||
const err = await res.json() as { error: string };
|
||||
this.querySelector('.error')!.textContent = err.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
customElements.define('login-form', LoginForm);
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
class ProjectEditor extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.render();
|
||||
}
|
||||
|
||||
async render(): Promise<void> {
|
||||
const res = await fetch('/api/projects');
|
||||
const projects = await res.json() as Project[];
|
||||
|
||||
this.innerHTML = `
|
||||
<div class="project-editor">
|
||||
<h2>Projects</h2>
|
||||
<form class="create-form">
|
||||
<input name="name" placeholder="New project name" required>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
<ul class="project-list">
|
||||
${projects.map(p => `
|
||||
<li data-id="${p.id}">
|
||||
<div class="project-header">
|
||||
<strong>${p.name}</strong>
|
||||
<a class="btn-edit" href="/editor.html?project=${p.id}&name=${encodeURIComponent(p.name)}">Edit</a>
|
||||
<button class="btn-members" data-id="${p.id}">Members</button>
|
||||
<button class="btn-delete" data-id="${p.id}" data-name="${p.name}">Delete</button>
|
||||
</div>
|
||||
<div class="members-panel" data-project="${p.id}" style="display:none"></div>
|
||||
</li>
|
||||
`).join('')}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.querySelector('.create-form')!.addEventListener('submit', async (e: Event) => {
|
||||
e.preventDefault();
|
||||
const form = e.target as HTMLFormElement;
|
||||
const name = (form.elements.namedItem('name') as HTMLInputElement).value.trim();
|
||||
const res = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
if (res.ok) this.render();
|
||||
});
|
||||
|
||||
this.querySelectorAll('.btn-delete').forEach(btn => {
|
||||
btn.addEventListener('click', async ( e ) => {
|
||||
|
||||
console.log( "Clicking:", e );
|
||||
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
|
||||
});
|
||||
|
||||
console.log( "Result:", ok );
|
||||
if (!ok) return;
|
||||
await fetch(`/api/projects/${el.dataset.id}`, { method: 'DELETE' });
|
||||
this.render();
|
||||
});
|
||||
});
|
||||
|
||||
this.querySelectorAll('.btn-members').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.toggleMembers((btn as HTMLElement).dataset.id!));
|
||||
});
|
||||
}
|
||||
|
||||
async toggleMembers(projectId: string): Promise<void> {
|
||||
const panel = this.querySelector(`.members-panel[data-project="${projectId}"]`) as HTMLElement;
|
||||
if (panel.style.display !== 'none') { panel.style.display = 'none'; return; }
|
||||
panel.style.display = 'block';
|
||||
await this.loadMembers(projectId, panel);
|
||||
}
|
||||
|
||||
async loadMembers(projectId: string, panel: HTMLElement): Promise<void> {
|
||||
const res = await fetch(`/api/projects/${projectId}/members`);
|
||||
const members = await res.json() as ProjectMember[];
|
||||
|
||||
panel.innerHTML = `
|
||||
<ul class="member-list">
|
||||
${members.map(m => `
|
||||
<li>${m.member_type}: ${m.member_id} — ${m.role}
|
||||
<button class="btn-remove-member" data-pid="${projectId}" data-mid="${m.id}">Remove</button>
|
||||
</li>
|
||||
`).join('') || '<li class="empty">No members</li>'}
|
||||
</ul>
|
||||
<form class="add-member-form">
|
||||
<select name="member_type">
|
||||
<option value="user">User ID</option>
|
||||
<option value="group">Group ID</option>
|
||||
</select>
|
||||
<input name="member_id" type="number" placeholder="ID" required>
|
||||
<select name="role">
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button type="submit">Add</button>
|
||||
</form>
|
||||
`;
|
||||
|
||||
panel.querySelectorAll('.btn-remove-member').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.loadMembers(projectId, panel);
|
||||
});
|
||||
});
|
||||
|
||||
(panel.querySelector('.add-member-form') as HTMLFormElement).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.loadMembers(projectId, panel);
|
||||
});
|
||||
}
|
||||
}
|
||||
customElements.define('project-editor', ProjectEditor);
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
class RegisterForm extends HTMLElement {
|
||||
connectedCallback(): void {
|
||||
this.innerHTML = `
|
||||
<form>
|
||||
<h2>Create Account</h2>
|
||||
<p class="error"></p>
|
||||
<label>Username<input name="username" autocomplete="username" required></label>
|
||||
<label>Password<input name="password" type="password" autocomplete="new-password" required></label>
|
||||
<button type="submit">Register</button>
|
||||
<a href="/login.html">Already have an account?</a>
|
||||
</form>
|
||||
`;
|
||||
this.querySelector('form')!.addEventListener('submit', async (e: Event) => {
|
||||
e.preventDefault();
|
||||
const form = e.target as HTMLFormElement;
|
||||
const data = Object.fromEntries(new FormData(form));
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (res.ok) {
|
||||
location.href = '/dashboard.html';
|
||||
} else {
|
||||
const err = await res.json() as { error: string };
|
||||
this.querySelector('.error')!.textContent = err.error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
customElements.define('register-form', RegisterForm);
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
import { Editor } from '../../editor/Editor.js';
|
||||
import { ContextMenuDirectory, ContextMenuEntry, ContextMenuSeparator } from '../context-menu/context-menu.js';
|
||||
|
||||
interface TabEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
panelType: string;
|
||||
element: HTMLElement;
|
||||
dirty: boolean;
|
||||
factory: () => HTMLElement;
|
||||
}
|
||||
|
||||
export interface EditorPanel extends HTMLElement {
|
||||
addContextMenuEntries(dir: ContextMenuDirectory): void;
|
||||
}
|
||||
|
||||
class TabContainer extends HTMLElement {
|
||||
tabs: TabEntry[] = [];
|
||||
activeId: string | null = null;
|
||||
uid = Math.random().toString(36).slice(2);
|
||||
|
||||
connectedCallback(): void {
|
||||
if (!this.id) this.id = 'tc-' + this.uid;
|
||||
this.classList.add('tab-container');
|
||||
this.innerHTML = `<div class="tc-bar"><div class="tc-tabs"></div><button class="tc-menu" title="Tab options">⋮</button></div><div class="tc-content"></div>`;
|
||||
|
||||
this.querySelector('.tc-menu')!.addEventListener('click', (e: Event) => {
|
||||
this.openMenu(e as MouseEvent);
|
||||
});
|
||||
|
||||
this.addEventListener('dragover', (e: DragEvent) => {
|
||||
if (e.dataTransfer?.types.includes('application/editor-tab')) {
|
||||
e.preventDefault();
|
||||
this.classList.add('tc-drop-target');
|
||||
}
|
||||
});
|
||||
this.addEventListener('dragleave', () => this.classList.remove('tc-drop-target'));
|
||||
this.addEventListener('drop', (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
this.classList.remove('tc-drop-target');
|
||||
const raw = e.dataTransfer?.getData('application/editor-tab');
|
||||
if (!raw) return;
|
||||
const { tabId, sourceContainerId } = JSON.parse(raw) as { tabId: string; sourceContainerId: string };
|
||||
if (sourceContainerId === this.id) return;
|
||||
const source = document.getElementById(sourceContainerId) as TabContainer | null;
|
||||
if (!source) return;
|
||||
const tab = source.extractTab(tabId);
|
||||
if (tab) this.receiveTab(tab);
|
||||
});
|
||||
|
||||
this.addEventListener( 'panel:label-change', ( e: Event ) => {
|
||||
const panel = e.target as HTMLElement;
|
||||
const tab = this.tabs.find( t => t.element === panel );
|
||||
if ( tab ) { tab.label = ( e as CustomEvent ).detail.label; this.renderBar(); }
|
||||
} );
|
||||
|
||||
Editor.get().onDocumentDirty.addListener( ( e ) =>
|
||||
{
|
||||
const tab = this.tabs.find( t => t.panelType === 'html-editor' && ( t.element as any ).currentPath === e.path );
|
||||
if ( tab ) { tab.dirty = true; this.renderBar(); }
|
||||
} );
|
||||
|
||||
Editor.get().onDocumentSaved.addListener( ( e ) =>
|
||||
{
|
||||
const tab = this.tabs.find( t => t.panelType === 'html-editor' && ( t.element as any ).currentPath === e.path );
|
||||
if ( tab ) { tab.dirty = false; this.renderBar(); }
|
||||
} );
|
||||
}
|
||||
|
||||
openMenu(e: MouseEvent): void {
|
||||
const btn = e.currentTarget as HTMLElement;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
const root = new ContextMenuDirectory(null);
|
||||
|
||||
const addDir = new ContextMenuDirectory(root, 'Add');
|
||||
const panelTypes = [
|
||||
{ label: 'HTML Editor', panelType: 'html-editor', tag: 'html-editor-panel' },
|
||||
{ label: 'File Tree', panelType: 'file-tree', tag: 'file-tree-panel' },
|
||||
];
|
||||
for (const pt of panelTypes) {
|
||||
addDir.add(new ContextMenuEntry(addDir, pt.label, () => {
|
||||
this.dispatchEvent(new CustomEvent('tab-container:add-panel', {
|
||||
bubbles: true,
|
||||
detail: { containerId: this.id, panelType: pt.panelType, tag: pt.tag, label: pt.label },
|
||||
}));
|
||||
}));
|
||||
}
|
||||
root.add(addDir);
|
||||
|
||||
root.add(new ContextMenuEntry(root, 'Duplicate', () => this.duplicateActive()));
|
||||
root.add(new ContextMenuEntry(root, 'Split', () => {
|
||||
this.dispatchEvent(new CustomEvent('tab-container:split', { bubbles: true, detail: { containerId: this.id } }));
|
||||
}));
|
||||
|
||||
const activeTab = this.tabs.find(t => t.id === this.activeId);
|
||||
if (activeTab) {
|
||||
const panel = activeTab.element as EditorPanel;
|
||||
if (typeof panel.addContextMenuEntries === 'function') {
|
||||
root.add(new ContextMenuSeparator(root));
|
||||
panel.addContextMenuEntries(root);
|
||||
}
|
||||
}
|
||||
|
||||
root.add(new ContextMenuSeparator(root));
|
||||
root.add(new ContextMenuEntry(root, 'Close', () => this.closeActive()));
|
||||
|
||||
root.show(rect.left, rect.bottom);
|
||||
}
|
||||
|
||||
duplicateActive(): void
|
||||
{
|
||||
if ( ! this.activeId )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const tab = this.tabs.find( t => t.id === this.activeId );
|
||||
|
||||
if ( ! tab )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const newId = tab.id + '-' + Math.random().toString( 36 ).slice( 2 );
|
||||
this.addTab( { id: newId, label: tab.label, panelType: tab.panelType }, tab.factory );
|
||||
}
|
||||
|
||||
closeActive(): void
|
||||
{
|
||||
if ( ! this.activeId )
|
||||
{
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
this.extractTab( this.activeId );
|
||||
}
|
||||
|
||||
addTab(config: { id: string; label: string; panelType: string }, factory: () => HTMLElement): void
|
||||
{
|
||||
const element = factory();
|
||||
element.style.display = 'none';
|
||||
this.querySelector('.tc-content')!.appendChild(element);
|
||||
this.tabs.push({ ...config, element, dirty: false, factory });
|
||||
this.activateTab(config.id);
|
||||
}
|
||||
|
||||
extractTab(tabId: string): TabEntry | null {
|
||||
const idx = this.tabs.findIndex(t => t.id === tabId);
|
||||
if (idx === -1) return null;
|
||||
const [tab] = this.tabs.splice(idx, 1);
|
||||
tab.element.remove();
|
||||
if (this.activeId === tabId) {
|
||||
this.activeId = this.tabs[0]?.id ?? null;
|
||||
}
|
||||
this.renderBar();
|
||||
this.showActive();
|
||||
return tab;
|
||||
}
|
||||
|
||||
receiveTab(tab: TabEntry): void {
|
||||
tab.element.style.display = 'none';
|
||||
this.querySelector('.tc-content')!.appendChild(tab.element);
|
||||
this.tabs.push(tab);
|
||||
this.activateTab(tab.id);
|
||||
}
|
||||
|
||||
activateTab(tabId: string): void {
|
||||
this.activeId = tabId;
|
||||
this.renderBar();
|
||||
this.showActive();
|
||||
}
|
||||
|
||||
renderBar(): void {
|
||||
const bar = this.querySelector('.tc-tabs')!;
|
||||
bar.innerHTML = this.tabs.map(t => `
|
||||
<div class="tc-tab${t.id === this.activeId ? ' active' : ''}" draggable="true"
|
||||
data-tab-id="${t.id}" data-source="${this.id}">
|
||||
${t.dirty ? '<span class="dirty-dot">●</span>' : ''}${t.label}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
bar.querySelectorAll('.tc-tab').forEach(el => {
|
||||
el.addEventListener('click', () => this.activateTab((el as HTMLElement).dataset.tabId!));
|
||||
el.addEventListener('dragstart', (e: Event) => {
|
||||
const de = e as DragEvent;
|
||||
const tabId = (el as HTMLElement).dataset.tabId!;
|
||||
de.dataTransfer?.setData('application/editor-tab', JSON.stringify({ tabId, sourceContainerId: this.id }));
|
||||
de.dataTransfer!.effectAllowed = 'move';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
showActive(): void {
|
||||
this.tabs.forEach(t => { t.element.style.display = t.id === this.activeId ? '' : 'none'; });
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tab-container', TabContainer);
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import { EventSlot } from '../library-ts/browser/events/EventSlot.js';
|
||||
import UndoManager from '../library-ts/browser/undo/UndoManager.js';
|
||||
|
||||
export interface DocumentOpenedEvent
|
||||
{
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DocumentPathEvent
|
||||
{
|
||||
path: string;
|
||||
}
|
||||
|
||||
export class Editor
|
||||
{
|
||||
static _instance: Editor = null;
|
||||
|
||||
static get(): Editor
|
||||
{
|
||||
if ( ! this._instance )
|
||||
{
|
||||
this._instance = new Editor();
|
||||
}
|
||||
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
projectId: string = '';
|
||||
projectName: string = '';
|
||||
openDocs: Map<string, { content: string; dirty: boolean }> = new Map();
|
||||
activeDoc: string | null = null;
|
||||
undoManager: UndoManager
|
||||
|
||||
|
||||
readonly onDocumentOpened: EventSlot<DocumentOpenedEvent> = new EventSlot();
|
||||
readonly onDocumentDirty: EventSlot<DocumentPathEvent> = new EventSlot();
|
||||
readonly onDocumentSaved: EventSlot<DocumentPathEvent> = new EventSlot();
|
||||
readonly onFilesChanged: EventSlot<void> = new EventSlot();
|
||||
|
||||
async openDocument( filePath: string ): Promise<void>
|
||||
{
|
||||
if ( ! this.openDocs.has( filePath ) )
|
||||
{
|
||||
const res = await fetch( `/api/files/${this.projectId}/${filePath}` );
|
||||
const content = await res.text();
|
||||
this.openDocs.set( filePath, { content, dirty: false } );
|
||||
}
|
||||
|
||||
this.activeDoc = filePath;
|
||||
const entry = this.openDocs.get( filePath )!;
|
||||
this.onDocumentOpened.dispatch( { path: filePath, content: entry.content } );
|
||||
}
|
||||
|
||||
markDirty( filePath: string, content: string ): void
|
||||
{
|
||||
const doc = this.openDocs.get( filePath );
|
||||
|
||||
if ( ! doc )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
doc.content = content;
|
||||
doc.dirty = true;
|
||||
this.onDocumentDirty.dispatch( { path: filePath } );
|
||||
}
|
||||
|
||||
async save( filePath: string ): Promise<void>
|
||||
{
|
||||
const doc = this.openDocs.get( filePath );
|
||||
|
||||
if ( ! doc || ! doc.dirty )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await fetch(
|
||||
`/api/files/${this.projectId}/${filePath}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
body: doc.content
|
||||
}
|
||||
);
|
||||
|
||||
doc.dirty = false;
|
||||
this.onDocumentSaved.dispatch( { path: filePath } );
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 89534bb72b8d2119ca5e12da05549edbd9133945
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
|
||||
export class LocaleManager
|
||||
{
|
||||
static _instance:LocaleManager;
|
||||
|
||||
static get $()
|
||||
{
|
||||
if ( ! this._instance )
|
||||
{
|
||||
this._instance = new LocaleManager();
|
||||
}
|
||||
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
_currentLocale = 'en';
|
||||
_cache = new Map<string, string>();
|
||||
|
||||
get currentLocale(): string
|
||||
{
|
||||
return this._currentLocale;
|
||||
}
|
||||
|
||||
setLocale( locale: string ):void
|
||||
{
|
||||
this._currentLocale = locale;
|
||||
}
|
||||
|
||||
async get( localePath:string ):Promise<string>
|
||||
{
|
||||
return this._loadLocale( localePath );
|
||||
}
|
||||
|
||||
async _loadLocale( relativePath: string ): Promise<string>
|
||||
{
|
||||
const key = `${this._currentLocale}/${relativePath}`;
|
||||
|
||||
if ( this._cache.has( key ) )
|
||||
{
|
||||
return this._cache.get( key );
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/locales/${this._currentLocale}/${relativePath}`);
|
||||
|
||||
if ( ! res.ok )
|
||||
{
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
const ext = relativePath.split('.').pop() ?? '';
|
||||
let value: string;
|
||||
|
||||
if ( ext === 'json' )
|
||||
{
|
||||
const data = await res.json() as { value: string };
|
||||
value = data.value;
|
||||
}
|
||||
else
|
||||
{
|
||||
value = await res.text();
|
||||
}
|
||||
|
||||
this._cache.set( key, value );
|
||||
return value;
|
||||
}
|
||||
|
||||
invalidateCache(): void
|
||||
{
|
||||
this._cache.clear();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
// Auto-generated by localeGenerator — do not edit manually.
|
||||
export class Locales {
|
||||
static greeting_txt = "greeting.txt";
|
||||
static commands = new class {
|
||||
static fileTreeCommands = new class {
|
||||
addFile_txt = "commands/file-tree-commands/add-file.txt";
|
||||
}();
|
||||
}();
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"strict": false,
|
||||
"strictNullChecks": false,
|
||||
"skipLibCheck": true,
|
||||
"types": [],
|
||||
"outDir": "public",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["src/library-ts/**/*"],
|
||||
"references": [
|
||||
{ "path": "./src/library-ts/browser/tsconfig.roject.json" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["server/**/*"]
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"strictNullChecks": false
|
||||
},
|
||||
"include": ["server/**/*", "src/library-ts/node/**/*"]
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
var NAV_DATA = {
|
||||
title: 'Workspace',
|
||||
path: 'index.html',
|
||||
children: [
|
||||
{
|
||||
title: 'Outline',
|
||||
path: 'outline/index.html',
|
||||
},
|
||||
{
|
||||
title: 'Guides',
|
||||
path: 'guides/index.html',
|
||||
children: [
|
||||
{ title: 'Writing TypeScript Code', path: 'guides/writing-typescript-code/index.html' },
|
||||
{ title: 'Locales', path: 'guides/locales/index.html' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
path: 'actions/index.html',
|
||||
children: [
|
||||
{ title: 'Update History', path: 'actions/update-history/index.html' },
|
||||
{ title: 'Update Outline', path: 'actions/update-outline/index.html' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'History',
|
||||
path: 'history/index.html',
|
||||
children: [
|
||||
{ title: 'Monday, 6 July 2026', path: 'history/2026/07-July/06-Monday/index.html' },
|
||||
{ title: 'Sunday, 5 July 2026', path: 'history/2026/07-July/05-Sunday/index.html' },
|
||||
{ title: 'Saturday, 4 July 2026', path: 'history/2026/07-July/04-Saturday/index.html' },
|
||||
{ title: 'Friday, 3 July 2026', path: 'history/2026/07-July/03-Friday/index.html' },
|
||||
{ title: 'Thursday, 2 July 2026', path: 'history/2026/07-July/02-Thursday/index.html' },
|
||||
{ title: 'Wednesday, 1 July 2026', path: 'history/2026/07-July/01-Wednesday/index.html' },
|
||||
]
|
||||
},
|
||||
]
|
||||
};
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
.wsnav
|
||||
{
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: #1a1d27;
|
||||
border-bottom: 1px solid #2a2d3a;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.wsnav-inner
|
||||
{
|
||||
width: 57em;
|
||||
margin: 0 auto;
|
||||
padding: 0.45rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.wsnav a
|
||||
{
|
||||
color: #7c8cff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.wsnav a:hover
|
||||
{
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Breadcrumb ── */
|
||||
|
||||
.wsnav-crumb
|
||||
{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.wsnav-sep
|
||||
{
|
||||
color: #4a4e6a;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.wsnav-current
|
||||
{
|
||||
color: #e2e4ed;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Siblings ── */
|
||||
|
||||
.wsnav-siblings
|
||||
{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
color: #7b7f96;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
.wsnav-sib-current
|
||||
{
|
||||
color: #e2e4ed;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.wsnav-sib-sep
|
||||
{
|
||||
color: #4a4e6a;
|
||||
}
|
||||
|
||||
/* ── Children ── */
|
||||
|
||||
.wsnav-children
|
||||
{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.88em;
|
||||
padding-top: 0.1rem;
|
||||
border-top: 1px solid #2a2d3a;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
(function ()
|
||||
{
|
||||
function getCurrentPath()
|
||||
{
|
||||
var href = window.location.href.replace(/\\/g, '/');
|
||||
var marker = 'workspace/';
|
||||
var idx = href.lastIndexOf(marker);
|
||||
if (idx === -1) return 'index.html';
|
||||
var after = href.slice(idx + marker.length);
|
||||
return after || 'index.html';
|
||||
}
|
||||
|
||||
function findNode(node, targetPath, ancestors)
|
||||
{
|
||||
if (node.path === targetPath) return { node: node, ancestors: ancestors };
|
||||
var children = node.children || [];
|
||||
for (var i = 0; i < children.length; i++)
|
||||
{
|
||||
var result = findNode(children[i], targetPath, ancestors.concat(node));
|
||||
if (result) return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function makeLink(node, label)
|
||||
{
|
||||
var a = document.createElement('a');
|
||||
a.href = NAV_ROOT + node.path;
|
||||
a.textContent = label || node.title;
|
||||
return a;
|
||||
}
|
||||
|
||||
function render()
|
||||
{
|
||||
var currentPath = getCurrentPath();
|
||||
var found = findNode(NAV_DATA, currentPath, []);
|
||||
|
||||
var nav = document.createElement('nav');
|
||||
nav.className = 'wsnav';
|
||||
|
||||
var inner = document.createElement('div');
|
||||
inner.className = 'wsnav-inner';
|
||||
nav.appendChild(inner);
|
||||
|
||||
// ── Breadcrumb ───────────────────────────────────────────────────
|
||||
var crumb = document.createElement('div');
|
||||
crumb.className = 'wsnav-crumb';
|
||||
|
||||
var ancestors = found ? found.ancestors : [];
|
||||
var currentNode = found ? found.node : null;
|
||||
|
||||
for (var i = 0; i < ancestors.length; i++)
|
||||
{
|
||||
var a = makeLink(ancestors[i]);
|
||||
crumb.appendChild(a);
|
||||
var sep = document.createElement('span');
|
||||
sep.className = 'wsnav-sep';
|
||||
sep.textContent = '›';
|
||||
crumb.appendChild(sep);
|
||||
}
|
||||
|
||||
var current = document.createElement('span');
|
||||
current.className = 'wsnav-current';
|
||||
current.textContent = currentNode ? currentNode.title : currentPath;
|
||||
crumb.appendChild(current);
|
||||
|
||||
inner.appendChild(crumb);
|
||||
|
||||
// ── Siblings ─────────────────────────────────────────────────────
|
||||
var parent = ancestors.length > 0 ? ancestors[ancestors.length - 1] : null;
|
||||
var siblings = parent ? (parent.children || []) : [];
|
||||
|
||||
if (siblings.length > 1)
|
||||
{
|
||||
var sibRow = document.createElement('div');
|
||||
sibRow.className = 'wsnav-siblings';
|
||||
|
||||
for (var j = 0; j < siblings.length; j++)
|
||||
{
|
||||
var sib = siblings[j];
|
||||
if (sib.path === currentPath)
|
||||
{
|
||||
var mark = document.createElement('span');
|
||||
mark.className = 'wsnav-sib-current';
|
||||
mark.textContent = sib.title;
|
||||
sibRow.appendChild(mark);
|
||||
}
|
||||
else
|
||||
{
|
||||
sibRow.appendChild(makeLink(sib));
|
||||
}
|
||||
|
||||
if (j < siblings.length - 1)
|
||||
{
|
||||
var div = document.createElement('span');
|
||||
div.className = 'wsnav-sib-sep';
|
||||
div.textContent = '·';
|
||||
sibRow.appendChild(div);
|
||||
}
|
||||
}
|
||||
|
||||
inner.appendChild(sibRow);
|
||||
}
|
||||
|
||||
// ── Children ─────────────────────────────────────────────────────
|
||||
var children = currentNode ? (currentNode.children || []) : [];
|
||||
|
||||
if (children.length > 0)
|
||||
{
|
||||
var childRow = document.createElement('div');
|
||||
childRow.className = 'wsnav-children';
|
||||
|
||||
for (var k = 0; k < children.length; k++)
|
||||
{
|
||||
childRow.appendChild(makeLink(children[k]));
|
||||
}
|
||||
|
||||
inner.appendChild(childRow);
|
||||
}
|
||||
|
||||
document.body.insertBefore(nav, document.body.firstChild);
|
||||
document.body.style.paddingTop = (nav.offsetHeight + 8) + 'px';
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading')
|
||||
{
|
||||
document.addEventListener('DOMContentLoaded', render);
|
||||
}
|
||||
else
|
||||
{
|
||||
render();
|
||||
}
|
||||
})();
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
*, *::before, *::after
|
||||
{
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root
|
||||
{
|
||||
--bg: #0f1117;
|
||||
--surface: #1a1d27;
|
||||
--border: #2a2d3a;
|
||||
--text: #e2e4ed;
|
||||
--muted: #7b7f96;
|
||||
--accent: #7c8cff;
|
||||
--tag-bg: #1e2235;
|
||||
--tag-text: #9ba4c7;
|
||||
--font-size: 20px;
|
||||
}
|
||||
|
||||
html
|
||||
{
|
||||
font-size: calc( var( --font-size ) );
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body
|
||||
{
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
line-height: 1.7;
|
||||
padding: calc( var( --font-size ) * 3 ) calc( var( --font-size ) * 1.5 ) calc( var( --font-size ) * 6 );
|
||||
}
|
||||
|
||||
.page
|
||||
{
|
||||
max-width: 40em;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
header
|
||||
{
|
||||
margin-bottom: calc( var( --font-size ) * 3 );
|
||||
padding-bottom: calc( var( --font-size ) * 1.5 );
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
header .date
|
||||
{
|
||||
font-size: calc( var( --font-size ) * 0.8 );
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin-bottom: calc( var( --font-size ) * 0.75 );
|
||||
}
|
||||
|
||||
header h1
|
||||
{
|
||||
font-size: calc( var( --font-size ) * 1.9 );
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
header .subtitle
|
||||
{
|
||||
margin-top: calc( var( --font-size ) * 0.5 );
|
||||
color: var(--muted);
|
||||
font-size: calc( var( --font-size ) * 0.95 );
|
||||
}
|
||||
|
||||
section
|
||||
{
|
||||
margin-bottom: calc( var( --font-size ) * 2.5 );
|
||||
}
|
||||
|
||||
section h2
|
||||
{
|
||||
font-size: calc( var( --font-size ) * 1.25 );
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin-bottom: calc( var( --font-size ) * 1 );
|
||||
}
|
||||
|
||||
.card
|
||||
{
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: calc( var( --font-size ) * 1.25 ) calc( var( --font-size ) * 1.5 );
|
||||
margin-bottom: calc( var( --font-size ) * 0.75 );
|
||||
}
|
||||
|
||||
.card h3
|
||||
{
|
||||
font-size: calc( var( --font-size ) * 1 );
|
||||
font-weight: 600;
|
||||
margin-bottom: calc( var( --font-size ) * 0.35 );
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.card p
|
||||
{
|
||||
font-size: calc( var( --font-size ) * 0.9 );
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.tags
|
||||
{
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: calc( var( --font-size ) * 0.4 );
|
||||
margin-top: calc( var( --font-size ) * 0.75 );
|
||||
}
|
||||
|
||||
.tag
|
||||
{
|
||||
background: var(--tag-bg);
|
||||
color: var(--tag-text);
|
||||
font-size: calc( var( --font-size ) * 0.75 );
|
||||
padding: calc( var( --font-size ) * 0.2 ) calc( var( --font-size ) * 0.6 );
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, "Cascadia Code", monospace;
|
||||
}
|
||||
|
||||
.decision
|
||||
{
|
||||
border-left: 3px solid var(--accent);
|
||||
padding-left: calc( var( --font-size ) * 1 );
|
||||
margin-bottom: calc( var( --font-size ) * 2 );
|
||||
}
|
||||
|
||||
.decision p
|
||||
{
|
||||
font-size: calc( var( --font-size ) * 0.9 );
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.decision strong
|
||||
{
|
||||
color: var(--text);
|
||||
display: block;
|
||||
margin-bottom: calc( var( --font-size ) * 0.2 );
|
||||
}
|
||||
|
||||
footer
|
||||
{
|
||||
margin-top: calc( var( --font-size ) * 4 );
|
||||
padding-top: calc( var( --font-size ) * 1.5 );
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: calc( var( --font-size ) * 0.8 );
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
a
|
||||
{
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover
|
||||
{
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
code
|
||||
{
|
||||
font-family: ui-monospace, "Cascadia Code", "Fira Code", monospace;
|
||||
font-size: 0.85em;
|
||||
color: var(--accent);
|
||||
background: var(--tag-bg);
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
pre
|
||||
{
|
||||
background: #0a0c14;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: calc( var( --font-size ) * 1 ) calc( var( --font-size ) * 1.25 );
|
||||
overflow-x: auto;
|
||||
font-family: ui-monospace, "Cascadia Code", "Fira Code", monospace;
|
||||
font-size: calc( var( --font-size ) * 0.82 );
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
margin-top: calc( var( --font-size ) * 0.75 );
|
||||
}
|
||||
|
||||
pre code
|
||||
{
|
||||
background: none;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Actions — Roject</title>
|
||||
<link rel="stylesheet" href="../_assets_/styles.css">
|
||||
<link rel="stylesheet" href="../_assets_/nav.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<header>
|
||||
<h1>Actions</h1>
|
||||
<p class="subtitle">Repeatable procedures for maintaining the project. Run these at the appropriate moment in a working session.</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
|
||||
<div class="card">
|
||||
<h3>Update History</h3>
|
||||
<p>
|
||||
At the end of a working session, create a day entry summarising what was built,
|
||||
key decisions made, and any structural changes. Then register it in the history
|
||||
index and nav data.
|
||||
</p>
|
||||
<p style="margin-top:0.75rem">
|
||||
<a href="update-history/index.html">Read the action</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Update Outline</h3>
|
||||
<p>
|
||||
When the project's scope, structure, or conventions change, update the outline
|
||||
and keep <code>CLAUDE.md</code> in sync. Covers which cards to update and when.
|
||||
</p>
|
||||
<p style="margin-top:0.75rem">
|
||||
<a href="update-outline/index.html">Read the action</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Roject — actions
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
<script>var NAV_ROOT = '../';</script>
|
||||
<script src="../_assets_/nav-data.js"></script>
|
||||
<script src="../_assets_/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Update History — Roject</title>
|
||||
<link rel="stylesheet" href="../../_assets_/styles.css">
|
||||
<link rel="stylesheet" href="../../_assets_/nav.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<header>
|
||||
<h1>Update History</h1>
|
||||
<p class="subtitle">How to write and register a session summary at the end of a working session.</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>Summary</h2>
|
||||
|
||||
<div class="card">
|
||||
<p>
|
||||
At the end of every working session, determine the correct day entry,
|
||||
create or update it, keep the history index and nav data in sync, then
|
||||
confirm with the user before committing and pushing everything to main.
|
||||
Always ask before acting — never assume the date and never run git commands
|
||||
without explicit confirmation.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Steps</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>Step 1 — Determine the date</h3>
|
||||
<p>
|
||||
Check whether a day entry already exists for today
|
||||
(i.e. <code>workspace/history/YYYY/MM-Month/DD-Day/index.html</code>
|
||||
for the current calendar date).
|
||||
</p>
|
||||
<p style="margin-top:0.75rem">
|
||||
<strong>If no entry exists for today:</strong> ask the user which day to use.
|
||||
A session may have started on the previous day, so do not assume today is
|
||||
correct. Present the options clearly and wait for an answer before continuing.
|
||||
Example prompt:
|
||||
</p>
|
||||
<pre><code>There is no entry for today (Monday, 6 July 2026) yet.
|
||||
Should I create one for today, or update an existing day?</code></pre>
|
||||
<p style="margin-top:0.75rem">
|
||||
<strong>If an entry already exists for today:</strong> skip this question —
|
||||
you will be updating that entry.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Step 2 — Create or update the day entry</h3>
|
||||
<p><strong>Creating a new entry:</strong></p>
|
||||
<p>
|
||||
Create the file at the path matching the chosen date:
|
||||
</p>
|
||||
<pre><code>workspace/history/YYYY/MM-Month/DD-Day/index.html</code></pre>
|
||||
<p style="margin-top:0.75rem">
|
||||
Use <code>workspace/_assets_/styles.css</code> and <code>nav.css</code>.
|
||||
The relative path from a day entry is:
|
||||
</p>
|
||||
<pre><code>../../../../_assets_/styles.css
|
||||
../../../../_assets_/nav.css</code></pre>
|
||||
<p style="margin-top:0.75rem">
|
||||
Set <code>NAV_ROOT</code> to <code>'../../../../'</code> for day entries.
|
||||
Cover: what was built, key decisions made, and any structural changes to the project.
|
||||
</p>
|
||||
|
||||
<p style="margin-top:1rem"><strong>Updating an existing entry:</strong></p>
|
||||
<p>
|
||||
If the session produced enough new distinct changes, append new cards to the
|
||||
"What we built" section and add decisions and structural changes as needed.
|
||||
If the changes are minor (small fixes, wording tweaks, no new features),
|
||||
adjust the existing content in place rather than appending.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Step 3 — Update the history index</h3>
|
||||
<p>
|
||||
Open <code>workspace/history/index.html</code>.
|
||||
</p>
|
||||
<p style="margin-top:0.75rem">
|
||||
<strong>New day:</strong> add a new card at the top of the list with a
|
||||
relative link — always include <code>index.html</code> explicitly:
|
||||
</p>
|
||||
<pre><code><div class="card">
|
||||
<h3><a href="2026/07-July/06-Monday/index.html">Monday, 6 July 2026</a></h3>
|
||||
<p>One-line summary of the session.</p>
|
||||
</div></code></pre>
|
||||
<p style="margin-top:0.75rem">
|
||||
<strong>Existing day:</strong> update the one-line summary in the existing card.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Step 4 — Update nav-data.js (new day only)</h3>
|
||||
<p>
|
||||
Skip this step if you are updating an existing day entry.
|
||||
</p>
|
||||
<p style="margin-top:0.75rem">
|
||||
Open <code>workspace/_assets_/nav-data.js</code> and add the new entry to the
|
||||
History children array at the top of the list (most recent first):
|
||||
</p>
|
||||
<pre><code>{ title: 'Monday, 6 July 2026', path: 'history/2026/07-July/06-Monday/index.html' },</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Step 5 — Confirm then commit and push</h3>
|
||||
<p>
|
||||
Before running any git command, present a confirmation prompt to the user.
|
||||
State exactly what day is being recorded, what the commit message will be,
|
||||
and where it will be pushed. Wait for the user to approve.
|
||||
</p>
|
||||
<p style="margin-top:0.75rem">Example prompt:</p>
|
||||
<pre><code>I would update Monday (06.07.26), commit everything as
|
||||
"history: file tree rename/delete, locale generator rewrite, workspace nav system"
|
||||
and push to main. Shall I proceed?</code></pre>
|
||||
<p style="margin-top:0.75rem">
|
||||
The commit message should be a useful one-liner that summarises the session.
|
||||
Only after the user confirms, run:
|
||||
</p>
|
||||
<pre><code>git add .
|
||||
git commit -m "MESSAGE"
|
||||
git push</code></pre>
|
||||
<p style="margin-top:0.75rem">
|
||||
If the push fails (e.g. remote has diverged), stop and report the error to
|
||||
the user. Do not attempt to resolve it automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Roject — update history
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
<script>var NAV_ROOT = '../../';</script>
|
||||
<script src="../../_assets_/nav-data.js"></script>
|
||||
<script src="../../_assets_/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Update Outline — Roject</title>
|
||||
<link rel="stylesheet" href="../../_assets_/styles.css">
|
||||
<link rel="stylesheet" href="../../_assets_/nav.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<header>
|
||||
<h1>Update Outline</h1>
|
||||
<p class="subtitle">When and how to keep the project outline current.</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>Summary</h2>
|
||||
|
||||
<div class="card">
|
||||
<p>
|
||||
The outline at <code>workspace/outline/index.html</code> is the single source of
|
||||
truth for what the project is, what exists, and what still needs work. Keep it
|
||||
current whenever the project's scope, structure, or conventions change. Also keep
|
||||
<code>CLAUDE.md</code> in sync — it should only point to the outline, never
|
||||
duplicate content.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>When to Update</h2>
|
||||
|
||||
<div class="decision">
|
||||
<strong>After adding or completing a feature</strong>
|
||||
<p>
|
||||
Move items between <em>What exists now</em> and <em>What still needs work</em>
|
||||
to reflect the current state of the application.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>After structural changes</strong>
|
||||
<p>
|
||||
Update the Technical Implementation cards when tsconfig setup, build commands,
|
||||
directory layout, or major architectural patterns change.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>After adding guides or actions</strong>
|
||||
<p>
|
||||
Add links to new guides or actions pages in the outline's reference section so
|
||||
they are discoverable. Also update <code>workspace/index.html</code> if a new
|
||||
top-level section (guides, actions) was added.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Steps</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>Step 1 — Update outline/index.html</h3>
|
||||
<p>
|
||||
Edit <code>workspace/outline/index.html</code>. At minimum update:
|
||||
</p>
|
||||
<ul>
|
||||
<li>The <em>What exists now</em> card</li>
|
||||
<li>The <em>What still needs work</em> card</li>
|
||||
<li>Any Technical Implementation card whose content changed</li>
|
||||
</ul>
|
||||
<p style="margin-top:0.75rem">
|
||||
Always use correct links — never omit <code>index.html</code> from hrefs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Step 2 — Check CLAUDE.md</h3>
|
||||
<p>
|
||||
Open <code>CLAUDE.md</code> at the project root and verify it still just points
|
||||
to the outline. If it has drifted and contains duplicated or stale content,
|
||||
trim it back so it only references <code>workspace/outline/index.html</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Step 3 — Update nav-data.js if pages were added</h3>
|
||||
<p>
|
||||
If new guide or action pages were created, add them to
|
||||
<code>workspace/_assets_/nav-data.js</code> under the correct parent node.
|
||||
Always use workspace-relative paths.
|
||||
</p>
|
||||
<pre><code>{ title: 'My New Guide', path: 'guides/my-new-guide/index.html' }</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Roject — update outline
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
<script>var NAV_ROOT = '../../';</script>
|
||||
<script src="../../_assets_/nav-data.js"></script>
|
||||
<script src="../../_assets_/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Guides — Roject</title>
|
||||
<link rel="stylesheet" href="../_assets_/styles.css">
|
||||
<link rel="stylesheet" href="../_assets_/nav.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<header>
|
||||
<h1>Guides</h1>
|
||||
<p class="subtitle">Reference guides for working in this project. Read the relevant guide before starting a task in that area.</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
|
||||
<div class="card">
|
||||
<h3>Writing TypeScript Code</h3>
|
||||
<p>
|
||||
Formatting rules, naming conventions, design patterns, and architectural
|
||||
principles for all TypeScript in this project. Read this before writing
|
||||
any application code.
|
||||
</p>
|
||||
<p style="margin-top:0.75rem">
|
||||
<a href="writing-typescript-code/index.html">Read the guide</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Locales</h3>
|
||||
<p>
|
||||
How user-visible strings are stored as files, auto-generated into typed
|
||||
TypeScript constants, and fetched at runtime via <code>LocaleManager</code>.
|
||||
Read this before adding or changing any user-visible text.
|
||||
</p>
|
||||
<p style="margin-top:0.75rem">
|
||||
<a href="locales/index.html">Read the guide</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Roject — guides
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
<script>var NAV_ROOT = '../';</script>
|
||||
<script src="../_assets_/nav-data.js"></script>
|
||||
<script src="../_assets_/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Locales — Roject</title>
|
||||
<link rel="stylesheet" href="../../_assets_/styles.css">
|
||||
<link rel="stylesheet" href="../../_assets_/nav.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<header>
|
||||
<h1>Locales</h1>
|
||||
<p class="subtitle">How user-visible strings are stored, generated, and used across the project.</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>Summary</h2>
|
||||
|
||||
<div class="card">
|
||||
<p>
|
||||
Roject uses a file-based locale system. Every user-visible string lives as a
|
||||
plain file under <code>locales/en/</code>. On server start the generator walks
|
||||
that tree and produces typed TypeScript classes into
|
||||
<code>src/locales/generated/</code>. In code, strings are fetched at runtime
|
||||
through <code>LocaleManager</code> using the generated path constants — for
|
||||
example <code>LocaleManager.$.get( Locales.FileTree.renameButton_txt )</code>.
|
||||
</p>
|
||||
<p style="margin-top:0.75rem">
|
||||
<strong>Rule:</strong> whenever you add a new user-visible string, add a locale
|
||||
file first, regenerate, then reference the constant — never hardcode the string
|
||||
in TypeScript directly.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Locale Source Files</h2>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Location</strong>
|
||||
<p>
|
||||
All locale source files live under <code>locales/en/</code>. The <code>en</code>
|
||||
directory is the English source of truth. One file = one string. The directory
|
||||
hierarchy is free-form — organise by feature area.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Supported formats</strong>
|
||||
<p>
|
||||
<code>.txt</code> — raw UTF-8 text, used for plain labels and messages.<br>
|
||||
<code>.html</code> — HTML markup, used when the string contains tags.<br>
|
||||
<code>.json</code> — flexible format; must contain at least <code>{ "value": "…" }</code>.
|
||||
Extra fields are ignored by the loader but can carry metadata for tools.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Companion context files (<code>.md</code>)</strong>
|
||||
<p>
|
||||
Any locale file may have a sibling whose name is the full filename plus
|
||||
<code>.md</code>. For example <code>rename-button.txt</code> may have a companion
|
||||
<code>rename-button.txt.md</code>. These files are human-readable notes for
|
||||
translators and contributors. The generator and the app ignore them completely —
|
||||
they are never served or compiled.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Code Generation</h2>
|
||||
|
||||
<div class="decision">
|
||||
<strong>When it runs</strong>
|
||||
<p>
|
||||
The generator (<code>server/localeGenerator.ts</code>) runs automatically every
|
||||
time the server starts via <code>npm start</code>. After adding, renaming, or
|
||||
removing any locale file, restart the server to regenerate
|
||||
<code>src/locales/generated/</code>. The generated files are checked into the
|
||||
repo so the frontend TypeScript compiler can see them without a running server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Output structure</strong>
|
||||
<p>
|
||||
The generator mirrors the <code>locales/en/</code> directory tree into
|
||||
<code>src/locales/generated/</code>. Each directory produces exactly one
|
||||
<code>.ts</code> file. The root directory produces
|
||||
<code>src/locales/generated/Locales.ts</code>. Never edit generated files
|
||||
manually — changes will be overwritten on the next server start.
|
||||
</p>
|
||||
<pre><code>locales/en/ → src/locales/generated/
|
||||
greeting.txt → Locales.ts (member: greeting_txt)
|
||||
commands/ → commands/
|
||||
file-tree-commands/ → Commands.ts (member: FileTreeCommands)
|
||||
add-file.txt → file-tree-commands/
|
||||
→ FileTreeCommands.ts (member: addFile_txt)</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Naming — class names (directories)</strong>
|
||||
<p>
|
||||
Directory names are converted to PascalCase: dashes and underscores split words,
|
||||
each word is capitalised, the first letter is always uppercase regardless of the
|
||||
original name.
|
||||
</p>
|
||||
<pre><code>commands → Commands
|
||||
file-tree-commands → FileTreeCommands
|
||||
my_panel → MyPanel</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Naming — member names (files)</strong>
|
||||
<p>
|
||||
File names keep the case of their first character. Dashes convert subsequent
|
||||
words to camelCase. The file extension is appended after an underscore, with
|
||||
dots in the extension also replaced by underscores.
|
||||
</p>
|
||||
<pre><code>add-file.txt → addFile_txt
|
||||
greeting.txt → greeting_txt
|
||||
MyFile.html → MyFile_html
|
||||
config.min.json → config_min_json</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Generated class shape</strong>
|
||||
<p>
|
||||
Each generated class has <code>static readonly</code> members. File members hold
|
||||
the locale path string (relative to <code>locales/en/</code>, extension included).
|
||||
Subdirectory members import and re-expose the child class so the entire tree is
|
||||
reachable from <code>Locales</code>.
|
||||
</p>
|
||||
<pre><code>// src/locales/generated/commands/file-tree-commands/FileTreeCommands.ts
|
||||
// Auto-generated — do not edit manually.
|
||||
|
||||
export class FileTreeCommands
|
||||
{
|
||||
static readonly addFile_txt = 'commands/file-tree-commands/add-file.txt';
|
||||
}
|
||||
|
||||
// src/locales/generated/commands/Commands.ts
|
||||
// Auto-generated — do not edit manually.
|
||||
import { FileTreeCommands } from './file-tree-commands/FileTreeCommands.js';
|
||||
|
||||
export class Commands
|
||||
{
|
||||
static readonly FileTreeCommands = FileTreeCommands;
|
||||
}
|
||||
|
||||
// src/locales/generated/Locales.ts
|
||||
// Auto-generated — do not edit manually.
|
||||
import { Commands } from './commands/Commands.js';
|
||||
|
||||
export class Locales
|
||||
{
|
||||
static readonly Commands = Commands;
|
||||
}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>LocaleManager API</h2>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Singleton access</strong>
|
||||
<p>
|
||||
<code>LocaleManager.$</code> is a static getter that returns the single instance.
|
||||
No parentheses needed. Import from
|
||||
<code>src/locales/LocaleManager.ts</code> (compiled to
|
||||
<code>public/locales/LocaleManager.js</code>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Fetching a string</strong>
|
||||
<p>
|
||||
<code>LocaleManager.$.get( path )</code> is async and returns
|
||||
<code>Promise<string></code>. Pass a generated path constant — never a raw
|
||||
string literal. The result is cached by key after the first fetch; subsequent
|
||||
calls return immediately from cache. If the server returns an error the method
|
||||
falls back to returning the path string itself so the UI never breaks silently.
|
||||
</p>
|
||||
<pre><code>const label = await LocaleManager.$.get( Locales.Commands.FileTreeCommands.addFile_txt );
|
||||
// → "Add file"</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Format handling</strong>
|
||||
<p>
|
||||
The manager detects the format from the file extension in the path.
|
||||
<code>.json</code> files are parsed and <code>data.value</code> is returned.
|
||||
All other extensions are returned as raw text.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Changing the active locale</strong>
|
||||
<p>
|
||||
The current locale defaults to <code>'en'</code>. Switch with
|
||||
<code>LocaleManager.$.setLocale( 'de' )</code>. After switching, call
|
||||
<code>LocaleManager.$.invalidateCache()</code> to clear cached strings so
|
||||
the next <code>get</code> call fetches from the new locale.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>How To — Add and Use a Locale String</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>Step 1 — Create the locale file</h3>
|
||||
<p>
|
||||
Choose a location under <code>locales/en/</code> that matches the feature.
|
||||
Create a <code>.txt</code> file whose name describes the string.
|
||||
</p>
|
||||
<pre><code>locales/en/file-tree/rename-button.txt
|
||||
content: Rename</code></pre>
|
||||
<p style="margin-top:0.75rem">
|
||||
Optionally add a companion context file:
|
||||
</p>
|
||||
<pre><code>locales/en/file-tree/rename-button.txt.md
|
||||
content: Label on the Rename entry in the file tree right-click context menu.</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Step 2 — Restart the server to regenerate</h3>
|
||||
<p>
|
||||
The generator runs on every <code>npm start</code> and creates or updates the
|
||||
generated TypeScript files. After this step the following files exist:
|
||||
</p>
|
||||
<pre><code>npm start
|
||||
|
||||
src/locales/generated/file-tree/FileTree.ts ← created
|
||||
src/locales/generated/Locales.ts ← updated (FileTree member added)</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Step 3 — Build</h3>
|
||||
<p>
|
||||
Compile the generated TypeScript so the browser can import the new constants.
|
||||
</p>
|
||||
<pre><code>npm run build</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Step 4 — Use in TypeScript</h3>
|
||||
<p>
|
||||
Import <code>LocaleManager</code> and <code>Locales</code>, then await the string.
|
||||
Always import <code>Locales</code> (not individual child classes) so the full
|
||||
path is visible at the call site and easy to trace back to the source file.
|
||||
</p>
|
||||
<pre><code>import { LocaleManager } from '../../locales/LocaleManager.js';
|
||||
import { Locales } from '../../locales/generated/Locales.js';
|
||||
|
||||
const label = await LocaleManager.$.get( Locales.FileTree.renameButton_txt );
|
||||
button.textContent = label;</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Roject — locales
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
<script>var NAV_ROOT = '../../';</script>
|
||||
<script src="../../_assets_/nav-data.js"></script>
|
||||
<script src="../../_assets_/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Writing TypeScript Code — Roject</title>
|
||||
<link rel="stylesheet" href="../../_assets_/styles.css">
|
||||
<link rel="stylesheet" href="../../_assets_/nav.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<header>
|
||||
<h1>Writing TypeScript Code</h1>
|
||||
<p class="subtitle">Conventions, formatting rules, and architectural patterns for all TypeScript in this project.</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>Summary</h2>
|
||||
|
||||
<div class="card">
|
||||
<p>
|
||||
This is a real application — not a library, not a framework demo. Write code accordingly:
|
||||
use singletons and hard coupling where they make sense, avoid event buses and loose
|
||||
indirection, prefer classes over interfaces except at data boundaries. Follow the
|
||||
formatting and naming rules below consistently across all files.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Formatting</h2>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Brackets on a new line</strong>
|
||||
<p>All opening and closing brackets go on their own line, never at the end of the preceding line.</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Indentation — soft tabs, 2 spaces</strong>
|
||||
<p>Never use hard tabs. Always use soft tabs with two spaces.</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Spacing around operators</strong>
|
||||
<p>All operators have spaces on both sides. Add a space after opening brackets and before closing brackets.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Naming</h2>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Default: lowercase camelCase</strong>
|
||||
<p>
|
||||
Variables, parameters, and instance members use lowercase camelCase.
|
||||
The following always start with an uppercase letter: class names,
|
||||
<code>const</code> / <code>static</code> / <code>readonly</code> members.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Underscore prefix for internal members</strong>
|
||||
<p>
|
||||
Prefer <code>public</code> or <code>protected</code> over <code>private</code>.
|
||||
Use a leading underscore on public members that are considered internal or
|
||||
unstable — this signals intent without locking down access.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Design</h2>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Static factory methods over parametric constructors</strong>
|
||||
<p>
|
||||
Avoid constructors with parameters that shadow a no-argument version.
|
||||
Use named static factory methods instead — they make intent explicit and
|
||||
keep construction flexible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Early return over nesting</strong>
|
||||
<p>
|
||||
Avoid deeply nested conditionals. Return, continue, or break as early as
|
||||
possible to keep the happy path at the lowest indentation level.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Extract blocks into named functions</strong>
|
||||
<p>
|
||||
Keep functions focused and concise. When a logical block deserves explanation,
|
||||
extract it into a separately named function rather than writing a long inline comment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Prefer <code>if / else if</code> over <code>switch</code></strong>
|
||||
<p>Conditional chains using <code>if / else if</code> are preferred over switch statements.</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Initialise all members</strong>
|
||||
<p>
|
||||
Assign an initial value to every class member at declaration.
|
||||
Nullable types are permitted — the goal is to make the default state
|
||||
explicit, not to avoid null entirely.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Constants on the left in comparisons</strong>
|
||||
<p>
|
||||
In equality checks, place the constant on the left:
|
||||
<code>null == value</code>, <code>"set" == command</code>.
|
||||
Null checks remain on the right.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Architecture</h2>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Classes, not interfaces, for application code</strong>
|
||||
<p>
|
||||
Use classes for all application logic. Reserve interfaces for data that
|
||||
crosses a system boundary — serialisable objects, API request/response shapes,
|
||||
data read from streams or files.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Singletons and hard coupling</strong>
|
||||
<p>
|
||||
This is a real app with known, stable relationships between its parts. Use
|
||||
singletons where a single shared instance makes sense. Couple components
|
||||
directly when the relationship is fixed — do not introduce indirection for
|
||||
its own sake.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>No event buses</strong>
|
||||
<p>
|
||||
Do not use event buses, pub/sub systems, or loose message-passing patterns.
|
||||
Call things directly. If a component needs to react to something, wire it up
|
||||
explicitly at the point where both sides are known.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>EventSlot for callbacks</strong>
|
||||
<p>
|
||||
When a component needs to expose a callback hook, use <code>EventSlot</code>
|
||||
from the shared library — not a plain function property or a custom event.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Example</h2>
|
||||
|
||||
<div class="card">
|
||||
<pre><code>export class MyClass
|
||||
{
|
||||
name = "";
|
||||
value = 0;
|
||||
data:string[] = [];
|
||||
|
||||
static create( name:string, value:number = 1 )
|
||||
{
|
||||
let mc = new MyClass();
|
||||
mc.name = name;
|
||||
mc.value = value;
|
||||
return mc;
|
||||
}
|
||||
|
||||
doStuff( command:string )
|
||||
{
|
||||
if ( command === null || command === undefined )
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ( "set" === command )
|
||||
{
|
||||
this.value = 1;
|
||||
}
|
||||
else if ( "add:3" === command )
|
||||
{
|
||||
this.value = 3;
|
||||
}
|
||||
else if ( "data:4:x" === command )
|
||||
{
|
||||
this.data[ 4 ] = "x";
|
||||
}
|
||||
}
|
||||
}</code></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Roject — writing TypeScript code
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
<script>var NAV_ROOT = '../../';</script>
|
||||
<script src="../../_assets_/nav-data.js"></script>
|
||||
<script src="../../_assets_/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
|
||||
|
||||
|
||||
[ Frontend ]
|
||||
|
||||
No tailwind. Raw css.
|
||||
|
||||
Use only custom elements with JS implementation:
|
||||
|
||||
<login-header>
|
||||
|
||||
</login-header>
|
||||
|
||||
|
||||
And than create for each a css:
|
||||
Like login-header.css
|
||||
|
||||
login-header
|
||||
{
|
||||
display:block
|
||||
}
|
||||
|
||||
|
||||
[ Base Structure ]
|
||||
|
||||
For the user-managment create:
|
||||
create, login, logout, delete
|
||||
|
||||
For users create:
|
||||
group-editing (creating, delete, adding/removings users/group )
|
||||
project-editing creating, delete, adding/removings users/group )
|
||||
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Session Summary — 1 July 2026</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, 1 July 2026</p>
|
||||
<h1>Roject — Initial Build</h1>
|
||||
<p class="subtitle">Session summary: from blank directory to a typed, UUID-based CMS skeleton.</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>What we built</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>Node.js + Express Backend</h3>
|
||||
<p>
|
||||
A REST API server handling authentication, groups, and projects.
|
||||
Organised into route modules with a shared auth middleware that guards
|
||||
all non-public endpoints via express-session.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">express</span>
|
||||
<span class="tag">express-session</span>
|
||||
<span class="tag">bcryptjs</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>User Management</h3>
|
||||
<p>
|
||||
Four operations: register, login, logout, and delete account.
|
||||
Passwords are hashed with bcrypt. Session stores the user's UUID and
|
||||
username for the lifetime of the browser session.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">POST /api/auth/register</span>
|
||||
<span class="tag">POST /api/auth/login</span>
|
||||
<span class="tag">POST /api/auth/logout</span>
|
||||
<span class="tag">DELETE /api/auth/me</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Groups & Projects</h3>
|
||||
<p>
|
||||
Both entities support create, delete, and member management.
|
||||
Members can be either a user or another group. Project members
|
||||
additionally carry a role (viewer, editor, admin).
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">/api/groups</span>
|
||||
<span class="tag">/api/projects</span>
|
||||
<span class="tag">member_type: user | group</span>
|
||||
<span class="tag">role</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Web Component Frontend</h3>
|
||||
<p>
|
||||
Five custom elements, each with its own <code>.ts</code> and <code>.css</code> file.
|
||||
No framework, no build tool beyond plain <code>tsc</code>, no Tailwind.
|
||||
CSS uses the element tag as the root selector with <code>display: block</code>.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag"><login-form></span>
|
||||
<span class="tag"><register-form></span>
|
||||
<span class="tag"><app-nav></span>
|
||||
<span class="tag"><group-editor></span>
|
||||
<span class="tag"><project-editor></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Key Decisions</h2>
|
||||
|
||||
<div class="decision">
|
||||
<strong>JSON file store instead of SQLite</strong>
|
||||
<p>
|
||||
better-sqlite3 requires native compilation and failed on Node 24 without
|
||||
build tools. Replaced with plain <code>fs.readFileSync</code> / <code>fs.writeFileSync</code>
|
||||
on per-table JSON files. Zero dependencies, good enough for MVP scale.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>TypeScript via plain tsc — no bundler</strong>
|
||||
<p>
|
||||
Server runs with <code>ts-node</code>. Frontend TypeScript lives in
|
||||
<code>src/components/</code> and compiles to <code>public/components/</code>
|
||||
via a separate <code>tsconfig.client.json</code> with <code>module: none</code>,
|
||||
keeping each component as a standalone global script.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>UUIDs for all IDs</strong>
|
||||
<p>
|
||||
Integer IDs would collide across parallel server instances. Switching to
|
||||
<code>crypto.randomUUID()</code> (built-in, no extra dependency) means
|
||||
any server can generate IDs independently, making future data merging
|
||||
or multi-node deployments straightforward.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Scripts at bottom of body</strong>
|
||||
<p>
|
||||
Custom elements are registered after the browser has parsed the HTML,
|
||||
so <code>connectedCallback</code> fires on an already-present element.
|
||||
Equivalent to <code>defer</code> in head, but explicit by position.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Project Structure</h2>
|
||||
<div class="card">
|
||||
<p>
|
||||
<code>server/</code> — TypeScript backend source<br>
|
||||
<code>src/components/</code> — TypeScript frontend source<br>
|
||||
<code>public/</code> — compiled JS, CSS, and HTML pages served statically<br>
|
||||
<code>data/</code> — auto-created JSON data files<br>
|
||||
<code>history/</code> — session logs (this file)<br>
|
||||
<code>CLAUDE.md</code> — project conventions loaded by Claude Code automatically
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What is missing</h2>
|
||||
<div class="card">
|
||||
<p>
|
||||
The HTML document editor itself — creating and editing actual content — is
|
||||
not yet built. The session covers only the scaffolding: auth, group management,
|
||||
project management, and the TypeScript + component infrastructure to build on.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Roject — session log — 1 July 2026
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
<script>var NAV_ROOT = '../../../../';</script>
|
||||
<script src="../../../../_assets_/nav-data.js"></script>
|
||||
<script src="../../../../_assets_/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
|
||||
|
||||
[ Technical ]
|
||||
|
||||
I want to create an application a server that uses Node.js in the backend and
|
||||
has an HTML frontend that is run in a browser (mobile and desktop).
|
||||
|
||||
[ MVP ]
|
||||
|
||||
|
||||
The app is used to create, edit and store (simple) HTML documents with assets in projects.
|
||||
The HTML documents are like normal web documents, mostly no JS from the user side, only as special plugins.
|
||||
|
||||
|
||||
The app has:
|
||||
- users
|
||||
- groups
|
||||
- projects
|
||||
|
||||
|
||||
User and groups have similar settings:
|
||||
- roles
|
||||
|
||||
|
||||
The roles are used for permissions that a user or group can have.
|
||||
|
||||
|
||||
[ Extra ]
|
||||
- The frontend can be run as desktop program (an electron app?)
|
||||
- The frontend can be run as native app pn android/iOS
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
|
|
@ -0,0 +1,418 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="3840"
|
||||
height="2160"
|
||||
viewBox="0 0 3840 2160"
|
||||
version="1.1"
|
||||
id="svg5"
|
||||
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
|
||||
sodipodi:docname="editor-mocks.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="0.18846164"
|
||||
inkscape:cx="2228.5703"
|
||||
inkscape:cy="1565.3053"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1017"
|
||||
inkscape:window-x="-8"
|
||||
inkscape:window-y="-8"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="g15567" /><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
|
||||
id="g15611"
|
||||
inkscape:label="Desktop Layout"
|
||||
inkscape:export-filename="desktop-layout.jpg"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"><g
|
||||
id="g15550"><rect
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:7.15699;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect14597"
|
||||
width="730.47949"
|
||||
height="501.31281"
|
||||
x="315.48615"
|
||||
y="219.20166"
|
||||
ry="0" /></g><g
|
||||
id="g14734"
|
||||
transform="matrix(0.16613337,0,0,0.16613337,362.04388,310.67985)"><rect
|
||||
style="fill:#00cf21;fill-opacity:1;stroke:none;stroke-width:47.7;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect298"
|
||||
width="3840"
|
||||
height="2160"
|
||||
x="0"
|
||||
y="0"
|
||||
inkscape:label="BG" /><rect
|
||||
style="fill:#2758ed;fill-opacity:1;stroke:none;stroke-width:51.8042;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect4863"
|
||||
width="1100.9788"
|
||||
height="2160"
|
||||
x="2739.0212"
|
||||
y="0" /><rect
|
||||
style="fill:#ff3434;fill-opacity:1;stroke:none;stroke-width:51.0098;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect1169"
|
||||
width="1067.4707"
|
||||
height="2160"
|
||||
x="0"
|
||||
y="0"
|
||||
inkscape:label="Left BG" /><rect
|
||||
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:29.456;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect7877"
|
||||
width="1005.2415"
|
||||
height="426.45239"
|
||||
x="31.114618"
|
||||
y="30.696035" /><rect
|
||||
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:35.1275;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect8037"
|
||||
width="1012.0112"
|
||||
height="644.77356"
|
||||
x="26.327707"
|
||||
y="1474.2224" /><rect
|
||||
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:30.3367;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect8091"
|
||||
width="1025.5504"
|
||||
height="443.3765"
|
||||
x="2779.592"
|
||||
y="30.696035" /><rect
|
||||
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:30.9548;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect8093"
|
||||
width="1028.9353"
|
||||
height="492.45645"
|
||||
x="2778.1897"
|
||||
y="1626.5396" /><rect
|
||||
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:51.5606;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect8095"
|
||||
width="1603.5996"
|
||||
height="819.09198"
|
||||
x="1105.351"
|
||||
y="30.696035" /><rect
|
||||
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:39.6625;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect8097"
|
||||
width="1596.8301"
|
||||
height="514.45782"
|
||||
x="1105.351"
|
||||
y="1607.6326" /><rect
|
||||
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:51.5606;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect8099"
|
||||
width="1603.5997"
|
||||
height="683.69897"
|
||||
x="1105.351"
|
||||
y="883.67175" /><rect
|
||||
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:43.7543;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect8153"
|
||||
width="1005.2415"
|
||||
height="940.94568"
|
||||
x="31.114618"
|
||||
y="487.64731" /><rect
|
||||
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:47.2668;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect8155"
|
||||
width="1025.5504"
|
||||
height="1076.3386"
|
||||
x="2779.592"
|
||||
y="504.57141" /><text
|
||||
xml:space="preserve"
|
||||
style="font-size:237.038px;line-height:1.4;font-family:'Exo 2';-inkscape-font-specification:'Exo 2, ';fill:#000000;fill-opacity:1;stroke:none;stroke-width:807.622;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
x="-9.2498178"
|
||||
y="-157.99304"
|
||||
id="text11629"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan11627"
|
||||
x="-9.2498178"
|
||||
y="-157.99304"
|
||||
style="fill:#000000;fill-opacity:1;stroke-width:807.622">DESKTOP LAYOUT</tspan></text></g></g><g
|
||||
id="g15567"
|
||||
inkscape:label="Mobile Layout"
|
||||
inkscape:export-filename="mobile-layout.jpg"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"><rect
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:6.17229;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect14703"
|
||||
width="361.58957"
|
||||
height="753.23767"
|
||||
x="315.48615"
|
||||
y="835.51782"
|
||||
ry="0" /><g
|
||||
id="g14718"
|
||||
transform="matrix(0.16613337,0,0,0.16613337,-782.86463,931.49465)"><rect
|
||||
style="fill:#71ff34;fill-opacity:1;stroke:none;stroke-width:52.3116;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect8157"
|
||||
width="1570.0914"
|
||||
height="3454.3083"
|
||||
x="6880.644"
|
||||
y="58.122753" /><rect
|
||||
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:47.7;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect8887"
|
||||
width="1475.7833"
|
||||
height="233.55287"
|
||||
x="6928.0718"
|
||||
y="101.54472" /><rect
|
||||
style="fill:#ec0000;fill-opacity:1;stroke:none;stroke-width:47.7;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect8889"
|
||||
width="176.01086"
|
||||
height="150.62468"
|
||||
x="6971.228"
|
||||
y="142.16261"
|
||||
ry="30.463417" /><rect
|
||||
style="fill:#ec0000;fill-opacity:1;stroke:none;stroke-width:47.7;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect9099"
|
||||
width="176.01086"
|
||||
height="150.62468"
|
||||
x="7177.7021"
|
||||
y="142.16261"
|
||||
ry="30.463417" /><rect
|
||||
style="fill:#71ff34;fill-opacity:1;stroke:none;stroke-width:47.7;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect9101"
|
||||
width="176.01086"
|
||||
height="150.62468"
|
||||
x="7485.7217"
|
||||
y="142.16261"
|
||||
ry="30.463417" /><rect
|
||||
style="fill:#71ff34;fill-opacity:1;stroke:none;stroke-width:47.7;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect9103"
|
||||
width="176.01086"
|
||||
height="150.62468"
|
||||
x="7692.1958"
|
||||
y="142.16261"
|
||||
ry="30.463417" /><rect
|
||||
style="fill:#001eec;fill-opacity:1;stroke:none;stroke-width:47.7;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect9105"
|
||||
width="176.01086"
|
||||
height="150.62468"
|
||||
x="7966.3667"
|
||||
y="142.16261"
|
||||
ry="30.463417" /><rect
|
||||
style="fill:#0006ec;fill-opacity:1;stroke:none;stroke-width:47.7;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect9107"
|
||||
width="176.01086"
|
||||
height="150.62468"
|
||||
x="8172.8408"
|
||||
y="142.16261"
|
||||
ry="30.463417" /><rect
|
||||
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:43.1107;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect9385"
|
||||
width="1457.3826"
|
||||
height="630.07062"
|
||||
x="6919.1587"
|
||||
y="396.25705" /><rect
|
||||
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:47.4438;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect9387"
|
||||
width="1462.1927"
|
||||
height="814.05536"
|
||||
x="6917.166"
|
||||
y="2664.0681" /><rect
|
||||
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:67.1696;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect9389"
|
||||
width="1457.3826"
|
||||
height="1529.5564"
|
||||
x="6919.1587"
|
||||
y="1069.6688" /><text
|
||||
xml:space="preserve"
|
||||
style="font-size:237.038px;line-height:1.4;font-family:'Exo 2';-inkscape-font-specification:'Exo 2, ';fill:#000000;fill-opacity:1;stroke:none;stroke-width:807.622;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
x="6857.895"
|
||||
y="-90.296562"
|
||||
id="text11633"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan11631"
|
||||
x="6857.895"
|
||||
y="-90.296562"
|
||||
style="fill:#000000;fill-opacity:1;stroke-width:807.622">MOBILE LAYOUT</tspan></text></g><rect
|
||||
style="fill:#71ff34;fill-opacity:1;stroke:none;stroke-width:47.7;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect19320"
|
||||
width="8.4419851"
|
||||
height="524.34106"
|
||||
x="439.9212"
|
||||
y="991.46423" /></g><g
|
||||
id="g15592"
|
||||
inkscape:label="Tab Container"
|
||||
inkscape:export-filename="tab-container.jpg"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96"><rect
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:4.46226;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect14736"
|
||||
width="366.08823"
|
||||
height="388.84644"
|
||||
x="812.58783"
|
||||
y="790.53125"
|
||||
ry="0" /><g
|
||||
id="g14759"
|
||||
transform="matrix(0.16613337,0,0,0.16613337,-677.14618,866.26405)"><rect
|
||||
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:67.1696;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect11737"
|
||||
width="1457.3826"
|
||||
height="1529.5564"
|
||||
x="9132.834"
|
||||
y="13.603622" /><text
|
||||
xml:space="preserve"
|
||||
style="font-size:237.038px;line-height:1.4;font-family:'Exo 2';-inkscape-font-specification:'Exo 2, ';fill:#000000;fill-opacity:1;stroke:none;stroke-width:807.622;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
x="9154.21"
|
||||
y="-83.526909"
|
||||
id="text11741"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan11739"
|
||||
x="9154.21"
|
||||
y="-83.526909"
|
||||
style="fill:#000000;fill-opacity:1;stroke-width:807.622">TAB CONTAINER</tspan></text><g
|
||||
id="g12181"
|
||||
transform="translate(189.55018,13.539297)"><rect
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:42.5034;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect11803"
|
||||
width="428.85165"
|
||||
height="186.08633"
|
||||
x="8989.7314"
|
||||
y="57.442371"
|
||||
ry="35.540653" /><path
|
||||
sodipodi:type="star"
|
||||
style="fill:#2242ed;fill-opacity:1;stroke:none;stroke-width:47.7;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="path11859"
|
||||
inkscape:flatsided="true"
|
||||
sodipodi:sides="6"
|
||||
sodipodi:cx="8782.6992"
|
||||
sodipodi:cy="178.31068"
|
||||
sodipodi:r1="35.921421"
|
||||
sodipodi:r2="31.108864"
|
||||
sodipodi:arg1="0"
|
||||
sodipodi:arg2="0.52359878"
|
||||
inkscape:rounded="0"
|
||||
inkscape:randomized="0"
|
||||
d="m 8818.6206,178.31068 -17.9607,31.10887 h -35.9214 l -17.9607,-31.10887 17.9607,-31.10886 35.9214,0 z"
|
||||
transform="translate(277.63813,-52.655507)" /><text
|
||||
xml:space="preserve"
|
||||
style="font-size:81.5528px;line-height:1.4;font-family:'Exo 2';-inkscape-font-specification:'Exo 2, ';fill:#2242ed;fill-opacity:1;stroke:none;stroke-width:277.862;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
x="9117.3545"
|
||||
y="152.55104"
|
||||
id="text12175"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan12173"
|
||||
x="9117.3545"
|
||||
y="152.55104"
|
||||
style="stroke-width:277.862">TAB A</tspan></text></g><g
|
||||
id="g12191"
|
||||
transform="translate(658.66288,13.539297)"><rect
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:42.5034;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect12183"
|
||||
width="423.77484"
|
||||
height="126.85191"
|
||||
x="8989.7314"
|
||||
y="57.442371"
|
||||
ry="35.540653" /><path
|
||||
sodipodi:type="star"
|
||||
style="fill:#2242ed;fill-opacity:1;stroke:none;stroke-width:47.7;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="path12185"
|
||||
inkscape:flatsided="true"
|
||||
sodipodi:sides="6"
|
||||
sodipodi:cx="8782.6992"
|
||||
sodipodi:cy="178.31068"
|
||||
sodipodi:r1="35.921421"
|
||||
sodipodi:r2="31.108864"
|
||||
sodipodi:arg1="0"
|
||||
sodipodi:arg2="0.52359878"
|
||||
inkscape:rounded="0"
|
||||
inkscape:randomized="0"
|
||||
d="m 8818.6206,178.31068 -17.9607,31.10887 h -35.9214 l -17.9607,-31.10887 17.9607,-31.10886 35.9214,0 z"
|
||||
transform="translate(277.63813,-52.655507)" /><text
|
||||
xml:space="preserve"
|
||||
style="font-size:81.5528px;line-height:1.4;font-family:'Exo 2';-inkscape-font-specification:'Exo 2, ';fill:#2242ed;fill-opacity:1;stroke:none;stroke-width:277.862;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
x="9117.3545"
|
||||
y="152.55104"
|
||||
id="text12189"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan12187"
|
||||
x="9117.3545"
|
||||
y="152.55104"
|
||||
style="stroke-width:277.862">TAB B</tspan></text></g><g
|
||||
id="g12201"
|
||||
transform="translate(1107.4314,13.539297)"><rect
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:42.5034;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect12193"
|
||||
width="423.77484"
|
||||
height="126.85191"
|
||||
x="8989.7314"
|
||||
y="57.442371"
|
||||
ry="35.540653" /><path
|
||||
sodipodi:type="star"
|
||||
style="fill:#2242ed;fill-opacity:1;stroke:none;stroke-width:47.7;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="path12195"
|
||||
inkscape:flatsided="true"
|
||||
sodipodi:sides="6"
|
||||
sodipodi:cx="8782.6992"
|
||||
sodipodi:cy="178.31068"
|
||||
sodipodi:r1="35.921421"
|
||||
sodipodi:r2="31.108864"
|
||||
sodipodi:arg1="0"
|
||||
sodipodi:arg2="0.52359878"
|
||||
inkscape:rounded="0"
|
||||
inkscape:randomized="0"
|
||||
d="m 8818.6206,178.31068 -17.9607,31.10887 h -35.9214 l -17.9607,-31.10887 17.9607,-31.10886 35.9214,0 z"
|
||||
transform="translate(277.63813,-52.655507)" /><text
|
||||
xml:space="preserve"
|
||||
style="font-size:81.5528px;line-height:1.4;font-family:'Exo 2';-inkscape-font-specification:'Exo 2, ';fill:#2242ed;fill-opacity:1;stroke:none;stroke-width:277.862;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
x="9117.3545"
|
||||
y="152.55104"
|
||||
id="text12199"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan12197"
|
||||
x="9117.3545"
|
||||
y="152.55104"
|
||||
style="stroke-width:277.862">TAB C</tspan></text></g><rect
|
||||
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:47.7;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect12305"
|
||||
width="1354.5125"
|
||||
height="1228.1417"
|
||||
x="9181.6758"
|
||||
y="221.7679"
|
||||
ry="35.540653" /><text
|
||||
xml:space="preserve"
|
||||
style="font-size:166.127px;line-height:1.4;font-family:'Exo 2';-inkscape-font-specification:'Exo 2, ';fill:#000000;fill-opacity:1;stroke:none;stroke-width:566.018;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
x="9331.6582"
|
||||
y="854.9328"
|
||||
id="text14493"><tspan
|
||||
sodipodi:role="line"
|
||||
id="tspan14491"
|
||||
x="9331.6582"
|
||||
y="854.9328"
|
||||
style="fill:#000000;fill-opacity:1;stroke-width:566.018">EDITOR PANEL</tspan></text></g></g><rect
|
||||
style="fill:#ff3434;fill-opacity:1;stroke:none;stroke-width:30.7902;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect15613"
|
||||
width="4.9744873"
|
||||
height="358.82635"
|
||||
x="445.05078"
|
||||
y="311.07126" /><rect
|
||||
style="fill:#00cf21;fill-opacity:1;stroke:none;stroke-width:30.7902;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect15978"
|
||||
width="4.9744873"
|
||||
height="358.82635"
|
||||
x="586.9895"
|
||||
y="311.07126" /><rect
|
||||
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:78.5388;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect16172"
|
||||
width="41.570229"
|
||||
height="144.45174"
|
||||
x="545.44604"
|
||||
y="441.79721" /><rect
|
||||
style="fill:#00cf21;fill-opacity:1;stroke:none;stroke-width:47.7;stroke-linecap:round;stroke-linejoin:round;paint-order:stroke fill markers"
|
||||
id="rect16278"
|
||||
width="49.713909"
|
||||
height="4.6899915"
|
||||
x="541.69403"
|
||||
y="391.37979" /></svg>
|
||||
|
After Width: | Height: | Size: 19 KiB |
|
|
@ -0,0 +1,9 @@
|
|||
Layout for Project Root
|
||||
|
||||
[ project-XXX ]
|
||||
- History
|
||||
- Documentation
|
||||
-- What is it about?
|
||||
- Root
|
||||
- Collections
|
||||
- To Do
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Session Summary — 2 July 2026</title>
|
||||
<link rel="stylesheet" href="../../../../_assets_/styles.css">
|
||||
<link rel="stylesheet" href="../../../../_assets_/nav.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<header>
|
||||
<p class="date">Thursday, 2 July 2026</p>
|
||||
<h1>Roject — Editor Build</h1>
|
||||
<p class="subtitle">Session summary: TypeScript conversion, UUID IDs, CLAUDE.md, and a full project editor with WYSIWYG HTML editing.</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>What we built</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>TypeScript Conversion</h3>
|
||||
<p>
|
||||
All server and frontend JavaScript converted to TypeScript. Server runs via
|
||||
<code>ts-node</code> using <code>tsconfig.json</code>. Frontend components
|
||||
compile from <code>src/components/</code> to <code>public/components/</code>
|
||||
via <code>tsconfig.client.json</code> with <code>module: none</code> — no bundler,
|
||||
each component is a self-contained global script. Interfaces added for all
|
||||
data shapes.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">typescript</span>
|
||||
<span class="tag">ts-node</span>
|
||||
<span class="tag">tsconfig.client.json</span>
|
||||
<span class="tag">module: none</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>UUID IDs</h3>
|
||||
<p>
|
||||
All entity IDs (users, groups, projects, members) switched from integers to
|
||||
UUIDs via <code>crypto.randomUUID()</code>. This allows multiple server instances
|
||||
to generate IDs independently without collision, enabling data merging and
|
||||
parallel deployments without a central ID authority.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">crypto.randomUUID()</span>
|
||||
<span class="tag">id: string</span>
|
||||
<span class="tag">no extra dependency</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Project File Storage</h3>
|
||||
<p>
|
||||
Creating a project now creates a real directory structure on the server under
|
||||
<code>storage/<project-id>/root/</code>. A default <code>index.html</code>
|
||||
with a Hello World <code><page-content></code> element is generated automatically.
|
||||
A new <code>/api/files</code> route handles tree listing and file read/write
|
||||
with path traversal protection.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">storage/</span>
|
||||
<span class="tag">GET /api/files/:id/tree</span>
|
||||
<span class="tag">GET /api/files/:id/*</span>
|
||||
<span class="tag">PUT /api/files/:id/*</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Editor Page</h3>
|
||||
<p>
|
||||
A full editor page (<code>/editor.html</code>) with a 3-panel layout: Left
|
||||
(file tree), Center (HTML editor), Right (empty, accepts dropped tabs). Panels
|
||||
are resizable via drag handles. Sections within a panel are arranged
|
||||
side by side and can be split using the ⊟ button on each tab container.
|
||||
Portrait mode shows one panel at a time, switched via a top bar.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag"><editor-shell></span>
|
||||
<span class="tag">3-panel layout</span>
|
||||
<span class="tag">resize handles</span>
|
||||
<span class="tag">portrait mode</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Tab Container</h3>
|
||||
<p>
|
||||
A custom <code><tab-container></code> element manages tabs within each
|
||||
panel section. Tabs are draggable between containers using the HTML5 Drag and
|
||||
Drop API. A dirty dot (●) appears on tabs with unsaved changes. The ⊟ split
|
||||
button creates a new side-by-side section in the same panel.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag"><tab-container></span>
|
||||
<span class="tag">drag and drop</span>
|
||||
<span class="tag">dirty indicator</span>
|
||||
<span class="tag">section split</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>HTML Editor Panel</h3>
|
||||
<p>
|
||||
A WYSIWYG editor that loads HTML documents in an iframe and makes the
|
||||
<code><page-content></code> element contenteditable. Changes are tracked
|
||||
via MutationObserver. Includes an undo/redo stack (up to 200 steps),
|
||||
Ctrl+S save, and a save button that highlights when the document is dirty.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag"><html-editor-panel></span>
|
||||
<span class="tag">iframe WYSIWYG</span>
|
||||
<span class="tag">MutationObserver</span>
|
||||
<span class="tag">undo/redo</span>
|
||||
<span class="tag">Ctrl+S</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>File Tree Panel</h3>
|
||||
<p>
|
||||
A <code><file-tree-panel></code> component fetches and renders the
|
||||
project's <code>root/</code> directory structure. HTML files are clickable
|
||||
and open in the HTML editor. Directories are collapsible. Non-HTML files
|
||||
are shown but not openable in this version.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag"><file-tree-panel></span>
|
||||
<span class="tag">collapsible dirs</span>
|
||||
<span class="tag">HTML-only editing</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Key Decisions</h2>
|
||||
|
||||
<div class="decision">
|
||||
<strong>LayoutPanel sections are arranged horizontally</strong>
|
||||
<p>
|
||||
Sections within a panel sit side by side (not stacked), so the Right panel
|
||||
can hold an object graph next to an inspector. Each section can contain
|
||||
multiple TabContainers stacked vertically. Portrait mode shows one section
|
||||
at a time via a secondary tab row below the main panel switcher.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Fixed 3-column structure, flexible rows</strong>
|
||||
<p>
|
||||
The editor has three named panels (Left, Center, Right). The column count
|
||||
is fixed because portrait mode maps each column to one top-bar button — a
|
||||
clean 1-to-1 relationship. Within each column, sections and tab containers
|
||||
can be freely split and resized.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Tab drag uses HTML5 DnD, not pointer events</strong>
|
||||
<p>
|
||||
HTML5 Drag and Drop handles cross-container tab moves via
|
||||
<code>application/editor-tab</code> in dataTransfer. The source container
|
||||
exposes an <code>extractTab()</code> method and the target calls
|
||||
<code>receiveTab()</code>, preserving the panel's DOM element and state
|
||||
across moves.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>EditorState on window, not imported</strong>
|
||||
<p>
|
||||
Since <code>module: none</code> forbids imports between frontend files,
|
||||
shared state lives on <code>window.editorState</code>, initialised
|
||||
synchronously in <code>editor-shell</code> before any child component
|
||||
connects. Other components wait via <code>customElements.whenDefined()</code>
|
||||
before accessing it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Real-time collaboration deferred</strong>
|
||||
<p>
|
||||
The spec described multi-user selections but building WebSocket sync would
|
||||
have consumed the full session. The document/state model is designed for it —
|
||||
EditorState can emit events that a future sync layer hooks into.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Project Structure</h2>
|
||||
<div class="card">
|
||||
<p>
|
||||
<code>server/storage.ts</code> — project file storage helpers<br>
|
||||
<code>server/routes/files.ts</code> — file tree + read/write API<br>
|
||||
<code>storage/</code> — project file directories (auto-created)<br>
|
||||
<code>src/components/editor-shell/</code> — main editor layout + EditorState<br>
|
||||
<code>src/components/tab-container/</code> — tabbed panel with drag-drop<br>
|
||||
<code>src/components/file-tree-panel/</code> — project file browser<br>
|
||||
<code>src/components/html-editor-panel/</code> — WYSIWYG iframe editor<br>
|
||||
<code>public/editor.html</code> — editor page entry point<br>
|
||||
<code>CLAUDE.md</code> — project conventions (UUID IDs, raw CSS, custom elements)
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What is missing</h2>
|
||||
<div class="card">
|
||||
<p>
|
||||
Real-time multi-user collaboration and shared selections are not implemented.
|
||||
The file tree is read-only — creating, renaming, and deleting files from the
|
||||
editor is not yet supported. The Right panel is empty by default and requires
|
||||
manual tab dragging to populate. Portrait secondary section switching (when a
|
||||
panel has multiple side-by-side sections) is defined but not yet wired up as
|
||||
a secondary tab row.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Roject — session log — 2 July 2026
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
<script>var NAV_ROOT = '../../../../';</script>
|
||||
<script src="../../../../_assets_/nav-data.js"></script>
|
||||
<script src="../../../../_assets_/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
|
|
@ -0,0 +1,64 @@
|
|||
Updating the project editing:
|
||||
[ Feature Update: Project Creation and Editing ]
|
||||
|
||||
As a user I want when I create a project to create a directory on the server.
|
||||
The root of this project directory and its files is not accessible directly for the user.
|
||||
It will contain settings and other organizational things.
|
||||
However there's one directory called 'root' where the user is able to edit all files and subdirectories.
|
||||
The creation of the project will create a first file "index.html" that can be viewed and edited, it contains a "Hello World!".
|
||||
|
||||
|
||||
This update will add the feature, that projects create a real directory structure with an initial file in root.
|
||||
|
||||
After the project was created in projects, in the project entry a button "Edit" appears next to "Members" and "Delete".
|
||||
|
||||
It will open the "editor", which is a new page that is used for editing several things inside the project.
|
||||
|
||||
|
||||
|
||||
[ Editor Page ]
|
||||
The editor page uses a system known from other editing system, where the editor has an active document, multiple selections
|
||||
and maybe other editor related states (maybe somthing indicating loading or dialogs etc). The selections represent
|
||||
possible other users that are also editing the document (via networking in realtime).
|
||||
It has typical (asynchronous) undo system, that allows to edit a document. It should also
|
||||
|
||||
The EditorUI is built up as a customizable UI, which contains EditorPanels
|
||||
that do things by listening to changes of the document, the selections
|
||||
and editor states.
|
||||
|
||||
The editor UI allows to rearrange the EditorPanels. They are organized in a resizable grid of LayoutPanels,
|
||||
which contain TabContainers, that hold multiple EditorPanels but show only the active one that is the selected tab.
|
||||
|
||||
For Landscape layouts:
|
||||
3 LayoutPanels are used as base. Each layout panel can be divided horizontally which forms a LayoutPanelSection, which can
|
||||
hold up to 3 TabContainers.
|
||||
|
||||
For Portrait layouts:
|
||||
Only one LayoutPanelSection is visible. The others can be selected by the mainbar on top. They also contain up to 3 TabContainers.
|
||||
|
||||
|
||||
Mocks can be found here:
|
||||
history/2026/07-July/02-Thursday/desktop-layout.jpg
|
||||
history/2026/07-July/02-Thursday/mobile-layout.jpg
|
||||
history/2026/07-July/02-Thursday/tab-container.jpg
|
||||
|
||||
|
||||
[ Editor Panels]
|
||||
There will be multiple, different EditorPanels that have different tasks.
|
||||
|
||||
-- ActiveDocumentTabContainer:TabContainer
|
||||
A special tab container, that contains a list of the active files that are currently in editing as well as
|
||||
an DocumentEditor, an archetype for document editors that will be able to save/load document
|
||||
|
||||
-- HTMLEditor:DocumentEditor
|
||||
A very rudimentary editor, that doesn't do much than just displaying the content and makes most elements "contenteditable" when they are
|
||||
clicked. Head and Body should not be changable. There will one element named "<page-content></page-content>" which will be editable.
|
||||
|
||||
-- Project File Tree Viewer
|
||||
Lists the directory structure of the project root
|
||||
|
||||
|
||||
[ Creating/Opening a fresh project ]
|
||||
|
||||
On Desktop opening a project in the editor should load the index.html file in the HTMLEditor in the center and
|
||||
show on the left side the project tree view.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -0,0 +1,221 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Session Summary — 3 July 2026</title>
|
||||
<link rel="stylesheet" href="../../../../_assets_/styles.css">
|
||||
<link rel="stylesheet" href="../../../../_assets_/nav.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<header>
|
||||
<p class="date">Friday, 3 July 2026</p>
|
||||
<h1>Roject — ES Modules & Library Integration</h1>
|
||||
<p class="subtitle">Session summary: confirm-dialog fix, ES module refactor, shared library submodule, TypeScript project references.</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>What we fixed</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>Confirm Dialog & Build Workflow</h3>
|
||||
<p>
|
||||
The project delete confirm dialog was not working because <code>npm run build</code>
|
||||
had not been run after the last code change, and the server had not been restarted.
|
||||
The compiled <code>.js</code> in <code>public/</code> was stale. No code change
|
||||
was needed — the fix was running the build and restart. Established that debugging
|
||||
frontend issues always requires a fresh build first.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">npm run build</span>
|
||||
<span class="tag">stale compiled output</span>
|
||||
<span class="tag">confirm-dialog</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>What we built</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>ES Module Refactor</h3>
|
||||
<p>
|
||||
The frontend was rewritten from <code>module: none</code> (global scripts) to
|
||||
proper ES modules. <code>tsconfig.client.json</code> now uses
|
||||
<code>module: ESNext</code> and <code>moduleResolution: bundler</code>.
|
||||
All HTML pages switched from <code><script src></code> to
|
||||
<code><script type="module" src></code>. Components now use
|
||||
<code>import</code> / <code>export</code> and no longer rely on global scope.
|
||||
The browser deduplicates shared module imports automatically.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">module: ESNext</span>
|
||||
<span class="tag">moduleResolution: bundler</span>
|
||||
<span class="tag">script type="module"</span>
|
||||
<span class="tag">import / export</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>EditorState as a Proper Module</h3>
|
||||
<p>
|
||||
<code>EditorState</code> was extracted from <code>editor-shell.ts</code> into its
|
||||
own module at <code>src/components/editor-state.ts</code>. It exports
|
||||
<code>setEditorState()</code> (called by <code>editor-shell</code> on init) and
|
||||
<code>getEditorState()</code> (called by any component that needs it).
|
||||
The <code>window.editorState</code> global is gone. Components import what they
|
||||
need directly.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">editor-state.ts</span>
|
||||
<span class="tag">getEditorState()</span>
|
||||
<span class="tag">setEditorState()</span>
|
||||
<span class="tag">no window globals</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Confirm Dialog Export</h3>
|
||||
<p>
|
||||
<code>showConfirmDialog</code> and <code>ConfirmDialogOptions</code> are now
|
||||
properly exported from <code>confirm-dialog.ts</code>.
|
||||
<code>project-editor.ts</code> imports <code>showConfirmDialog</code> directly
|
||||
instead of using a <code>/// <reference path></code> directive. The
|
||||
redundant <code><script></code> tag for <code>confirm-dialog.js</code> in
|
||||
<code>projects.html</code> was removed — the browser loads it via the import graph.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">export showConfirmDialog</span>
|
||||
<span class="tag">import instead of reference</span>
|
||||
<span class="tag">no redundant script tag</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Shared Library as Git Submodule</h3>
|
||||
<p>
|
||||
A personal TypeScript library was added as a git submodule at
|
||||
<code>src/library-ts/</code>. The library has two parts:
|
||||
<code>browser/</code> (DOM-capable, usable with shims on Node) and
|
||||
<code>node/</code> (Node.js only). Placing the submodule inside <code>src/</code>
|
||||
keeps <code>rootDir: "src"</code> intact so the output URL structure in
|
||||
<code>public/</code> is unchanged.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">git submodule</span>
|
||||
<span class="tag">src/library-ts/</span>
|
||||
<span class="tag">browser/ + node/</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>TypeScript Project References</h3>
|
||||
<p>
|
||||
The library was written without <code>strict: true</code>, causing hundreds of
|
||||
errors when compiled with Roject's strict settings. TypeScript project references
|
||||
solve this: a new <code>tsconfig.roject.json</code> inside
|
||||
<code>src/library-ts/browser/</code> compiles the browser part separately with
|
||||
<code>strict: false</code>, <code>composite: true</code>, and
|
||||
<code>declaration: true</code>. Roject's <code>tsconfig.client.json</code>
|
||||
excludes <code>src/library-ts/**/*</code> and references the library project.
|
||||
Build now runs as <code>tsc --build tsconfig.client.json</code>.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">project references</span>
|
||||
<span class="tag">composite: true</span>
|
||||
<span class="tag">declaration: true</span>
|
||||
<span class="tag">tsc --build</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Server-Side Library Configuration</h3>
|
||||
<p>
|
||||
The node part of the library caused the same strict errors when compiled by
|
||||
<code>ts-node</code>. A dedicated <code>tsconfig.ts-node.json</code> extends the
|
||||
server tsconfig but turns off <code>strictNullChecks</code> (the only check the
|
||||
library violates) and adds <code>src/library-ts/node/**/*</code> to the include.
|
||||
The start script now passes <code>--project tsconfig.ts-node.json</code> to
|
||||
<code>ts-node</code>.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">tsconfig.ts-node.json</span>
|
||||
<span class="tag">strictNullChecks: false</span>
|
||||
<span class="tag">ts-node --project</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Key Decisions</h2>
|
||||
|
||||
<div class="decision">
|
||||
<strong>ES modules over global scripts</strong>
|
||||
<p>
|
||||
<code>module: none</code> was originally chosen to avoid a bundler, but it also
|
||||
ruled out <code>import</code> / <code>export</code> and forced the
|
||||
<code>window.editorState</code> workaround. Browsers support ES modules natively
|
||||
via <code>type="module"</code> scripts — no bundler needed. The switch removes
|
||||
all global-scope workarounds and makes the library integration straightforward.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Submodule inside <code>src/</code>, not at project root</strong>
|
||||
<p>
|
||||
Placing the submodule at <code>src/library-ts/</code> keeps
|
||||
<code>rootDir: "src"</code> in the client tsconfig unchanged. A submodule at the
|
||||
project root would require either changing rootDir (breaking the
|
||||
<code>/components/...</code> URL structure) or setting up path aliases.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Separate tsconfig per compilation unit, not a single monolithic one</strong>
|
||||
<p>
|
||||
Three tsconfigs now serve distinct purposes: <code>tsconfig.json</code> (server,
|
||||
strict), <code>tsconfig.client.json</code> (frontend, strict, references library),
|
||||
<code>tsconfig.ts-node.json</code> (runtime, relaxed for library compatibility).
|
||||
This keeps strict checking on all first-party code without patching the library.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Only <code>strictNullChecks</code> turned off for ts-node</strong>
|
||||
<p>
|
||||
All three errors in the node library were <code>strictNullChecks</code> violations.
|
||||
Rather than disabling all of <code>strict</code>, only that one flag is overridden
|
||||
in <code>tsconfig.ts-node.json</code>. Server code retains
|
||||
<code>noImplicitAny</code>, <code>strictFunctionTypes</code>,
|
||||
<code>strictPropertyInitialization</code>, and all other strict checks.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Project Structure</h2>
|
||||
<div class="card">
|
||||
<p>
|
||||
<code>src/components/editor-state.ts</code> — EditorState module (getEditorState / setEditorState)<br>
|
||||
<code>src/library-ts/browser/</code> — shared browser library (git submodule)<br>
|
||||
<code>src/library-ts/node/</code> — shared node library (git submodule)<br>
|
||||
<code>src/library-ts/browser/tsconfig.roject.json</code> — library browser tsconfig (strict: false, composite)<br>
|
||||
<code>tsconfig.client.json</code> — frontend: ESNext modules, references library<br>
|
||||
<code>tsconfig.ts-node.json</code> — ts-node runtime: extends server tsconfig, strictNullChecks: false<br>
|
||||
<code>public/library-ts/browser/</code> — compiled library output with .d.ts files
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Roject — session log — 3 July 2026
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
<script>var NAV_ROOT = '../../../../';</script>
|
||||
<script src="../../../../_assets_/nav-data.js"></script>
|
||||
<script src="../../../../_assets_/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
[ Documentation Update ]
|
||||
The project needs a proper html developer documentation.
|
||||
It will get an html documentation that will be readable for the human and agent developers (replacing claude.md)
|
||||
|
||||
It will get its first index.html in "doc/outline/index.html" in the root path.
|
||||
|
||||
It will contain the entry point for the agent to ensure it is on track.
|
||||
|
||||
|
||||
It contains the following paragraphs:
|
||||
|
||||
- project outline:
|
||||
Explains what the project is about (high level short), what it does (currently), and what is should do (todos for the ).
|
||||
|
||||
- technical implementation:
|
||||
Explains the tech stack, includes a link to the coding guidelines "doc/outline/coding-guidelines",
|
||||
which should be copied from https://rokojori.com/en/labs/rokojori-action-library/coding-guidelines.
|
||||
|
||||
- actions:
|
||||
Contains a set of tasks that are used repeatadly. It should contain for each action a short summary
|
||||
what it does. The first two actions are:
|
||||
|
||||
Update History:
|
||||
This action updates what was done in the last session of the day. It should create an entry for the day the session started
|
||||
and use the "history/styles.css" to create an html document. An example path for the 4th July is "history/2026/07-July/04-Saturday"
|
||||
It should also update the main "history/index.html", which contains a list of all history entries. On every update this file is updated.
|
||||
|
||||
Update Documentation:
|
||||
This action updates the documentation, by updating the project outline and maybe more things for the documentation.
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Session Summary — 4 July 2026</title>
|
||||
<link rel="stylesheet" href="../../../../_assets_/styles.css">
|
||||
<link rel="stylesheet" href="../../../../_assets_/nav.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<header>
|
||||
<p class="date">Saturday, 4 July 2026 — Sunday, 5 July 2026</p>
|
||||
<h1>Roject — Developer Documentation</h1>
|
||||
<p class="subtitle">Session summary: doc/ folder restructure, project outline, coding guidelines, history index, CLAUDE.md cleanup.</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>What we built</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>Folder Restructure</h3>
|
||||
<p>
|
||||
The <code>history/</code> folder moved to <code>doc/history/</code> and
|
||||
<code>history/styles.css</code> moved to <code>doc/_assets_/styles.css</code>
|
||||
to serve as a shared stylesheet for all documentation. All existing history
|
||||
entry CSS paths were updated accordingly. The new <code>doc/</code> root is
|
||||
the single home for all project documentation going forward.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">doc/</span>
|
||||
<span class="tag">doc/_assets_/styles.css</span>
|
||||
<span class="tag">doc/history/</span>
|
||||
<span class="tag">doc/outline/</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Project Documentation Outline</h3>
|
||||
<p>
|
||||
Created <code>doc/outline/index.html</code> — the main developer reference,
|
||||
readable by both humans and agent contributors. It contains three sections:
|
||||
a project outline (what Roject is, current state, loose todos), a technical
|
||||
implementation overview (backend, frontend, shared library with tags), and
|
||||
an actions section that defines repeatable tasks for Update History and
|
||||
Update Documentation.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">doc/outline/index.html</span>
|
||||
<span class="tag">project outline</span>
|
||||
<span class="tag">tech stack</span>
|
||||
<span class="tag">actions</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Coding Guidelines</h3>
|
||||
<p>
|
||||
Created <code>doc/outline/coding-guidelines/index.html</code> based on the
|
||||
rokojori action library guidelines. Covers formatting (brackets on new line,
|
||||
2-space soft tabs, spacing around operators), naming (camelCase, uppercase
|
||||
for class names and statics, underscore prefix for internal members), and
|
||||
design rules (static factory methods, early return, extract to named
|
||||
functions, if/else over switch, initialise all members). Includes a
|
||||
TypeScript example. C# and PHP examples were omitted.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">doc/outline/coding-guidelines/</span>
|
||||
<span class="tag">TypeScript example</span>
|
||||
<span class="tag">formatting</span>
|
||||
<span class="tag">naming</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>History Index</h3>
|
||||
<p>
|
||||
Created <code>doc/history/index.html</code> — a chronological list of all
|
||||
session entries, most recent first, each with a one-line summary and a link
|
||||
to the full entry. This file is updated by the Update History action at the
|
||||
end of every session.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">doc/history/index.html</span>
|
||||
<span class="tag">session index</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Stylesheet Extensions</h3>
|
||||
<p>
|
||||
Added <code>a</code>, <code>code</code>, <code>pre</code>, and
|
||||
<code>pre code</code> styles to <code>doc/_assets_/styles.css</code> to
|
||||
support the coding guidelines page. Inline code gets the accent colour on a
|
||||
dark tag background; code blocks get a darker background with monospace font
|
||||
and horizontal scrolling.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">pre</span>
|
||||
<span class="tag">code</span>
|
||||
<span class="tag">doc/_assets_/styles.css</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>CLAUDE.md</h3>
|
||||
<p>
|
||||
Stripped <code>CLAUDE.md</code> down to a single line pointing to
|
||||
<code>doc/outline/index.html</code>. All content that was in CLAUDE.md
|
||||
(tech stack, conventions, IDs, TypeScript setup, commands, project structure)
|
||||
is now in the outline with more detail.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">CLAUDE.md</span>
|
||||
<span class="tag">pointer only</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Key Decisions</h2>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Documentation lives in <code>doc/</code>, not project root</strong>
|
||||
<p>
|
||||
Keeping all documentation under a single <code>doc/</code> directory makes the
|
||||
root cleaner and gives history and outline a shared home without polluting the
|
||||
server or source directories.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Todos are a loose reminder, not a backlog</strong>
|
||||
<p>
|
||||
The "what still needs work" card in the outline is intentionally written as
|
||||
prose, not a checklist. It is updated on the go as features are added rather
|
||||
than maintained as a formal task tracker.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Actions are written for agents and humans alike</strong>
|
||||
<p>
|
||||
The actions section in the outline describes repeatable tasks (Update History,
|
||||
Update Documentation) with enough detail that an agent can execute them
|
||||
correctly without asking follow-up questions: which file to create, what CSS
|
||||
path to use, what to update after.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Project Structure</h2>
|
||||
<div class="card">
|
||||
<p>
|
||||
<code>doc/_assets_/styles.css</code> — shared stylesheet for all doc pages<br>
|
||||
<code>doc/outline/index.html</code> — main developer documentation<br>
|
||||
<code>doc/outline/coding-guidelines/index.html</code> — formatting and naming rules<br>
|
||||
<code>doc/history/index.html</code> — session history index<br>
|
||||
<code>doc/history/YYYY/MM-Month/DD-Day/index.html</code> — individual session entries<br>
|
||||
<code>CLAUDE.md</code> — one-line pointer to <code>doc/outline/index.html</code>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Roject — session log — 4–5 July 2026
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
<script>var NAV_ROOT = '../../../../';</script>
|
||||
<script src="../../../../_assets_/nav-data.js"></script>
|
||||
<script src="../../../../_assets_/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
[ Localization System ]
|
||||
There will be a directory "locales", with a subdirectory called "en". In this directory can have files or subdirectories. It can be either a file ending with html, txt, json. All the files are local files containing localization data. html will have a root element, txt just raw utf8 text and the json file will be an object containing at least a member "value" with a string. There will also be md files that are only valid when they are also available as html, txt or json. For instance a "greeting.html" can have a "greeting.html.md". This md file which is markdown and contains context information for the translation, it is optional.
|
||||
|
||||
Every time a file was added, removed or changed the system updates and creates a new TypeScript class that represents the structure as nested classes where a class represents a directory and a file a member. The root class name is Locales. The names should be the dash separated names, that are converted to allowed names in TypeScript, the dots in the filenames are replaced by "_". So a "greeting.html" becomes "greetings_html". The member types are strings that represent the path relative from their root locale "en". So "locales/en/greeting.html" will be Locales.greeting_html and contains "greeting.html". Something in a directory "locales/en/editor/confirm-project-deletion.html" becomes Locales.editor.confirmProjectDeletion_html with "editor/confirm-project-deletion.html" as value
|
||||
|
||||
The app will have a LocaleManager that can change the language at runtime where it will change the path from en to the selected locale. A translation for German would be in de. All strings will be replaced after placed in elements directly. Most elements that are created/rendered need to resolve the text.
|
||||
|
||||
[ Command Class ]
|
||||
There will be commands in the editor, which have:
|
||||
- A short title
|
||||
- A small info
|
||||
- Multiple assignments for assigning shortcuts for keys, mouse with conditions
|
||||
The conditions are simple flags of the application like hasSelection or textDialogOpen etc.
|
||||
|
||||
The commands can be in its own directory commands. With the highest class a CommandManager which can than have member classes for sub domains. Like FileTreeCommands or HTMLEditorCommands => CommandManager.FileTreeCommands.addFile.
|
||||
The commands should have a structure where the have an execute function that gives the execution context, it can be null and than its created automatically. It's for tracking recursive commands that maybe are buggy and later stuff:
|
||||
|
||||
CommandManager.FileTreeCommands.addFile.execute( CommandManager.createContext() );
|
||||
CommandManager.FileTreeCommands.addFile.execute( context );
|
||||
|
||||
|
||||
|
||||
[ ContextMenu Class ]
|
||||
There should be a context menu class that can be used in the editor to create nested menus.
|
||||
For the first iteration the main ContextMenuItems are a ContextMenuEntry for a "close panel" or a ContextMenuDirectory which opens another nested menu like "panels >" that contains more ContextMenuEntries. There should also be ContextMenuReadOnlyEntry to allow for showing info text. Also there should be a ContextMenuSeparator which adds a separator. In the future there could be other types like a row of buttons or maybe a slider or an image that is as high as multiple rows. It should also be possible to define a non-closing behaviour, where items are not closed on Click/Tap.
|
||||
A ContextMenu is constructed by creating a root ContextMenuDirectory without a parent (it is null) and then adding ContextMenuItems (like the entry, directory, separator etc). A ContextMenuItem always gets the parent ContextMenuDirectory passed to be able to resolve its hierarchy. The root has null as parent.
|
||||
The ContextMenuDirectory has a method named show which will add the DOM elements. They can be created "lazy" on show.
|
||||
A ContextMenuDirectory will hide itself when a sibling is shown, so that only one child of a parent is visible and not multiple siblings.
|
||||
|
||||
[ Tab Container/EditorPanel Update ]
|
||||
The button that is used for splitting should be replaced by a generic menu button that opens a ContextMenu. Its first options should be "add >", "duplicate", "split" and "close". "Add" contains a list of possible EditorPanels, which will be added to the tab container. "Duplicate" duplicates the active tab. "Split" is the current function that is used to split the panels and "close" will close the active tab.
|
||||
|
||||
Every EditorPanel should have a function to add ContextMenuEntries to a passed ContextMenuDirectory. If it has no items, it should pass an empty info.
|
||||
|
||||
[ File Tree Panel Update ]
|
||||
It will get two commands that are AddFile and AddDirectory, which will do what their names say. When nothing is selected it creates it in the root, when something is selected it takes the most first, highest directory.
|
||||
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Session Summary — 5 July 2026</title>
|
||||
<link rel="stylesheet" href="../../../../_assets_/styles.css">
|
||||
<link rel="stylesheet" href="../../../../_assets_/nav.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<header>
|
||||
<p class="date">Sunday, 5 July 2026</p>
|
||||
<h1>Roject — Session Summary</h1>
|
||||
<p class="subtitle">
|
||||
Editor overhaul: ContextMenu, EventSlot refactor, session persistence,
|
||||
tab drag fixes, panel header redesign, FileTree sub-root, Init/Pin buttons,
|
||||
resize handling, portrait bar move, layout persistence per user per device.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>What we built</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>ContextMenu Class Hierarchy</h3>
|
||||
<p>
|
||||
Introduced a plain class hierarchy (not a Web Component) for building
|
||||
nested context menus: <code>ContextMenuDirectory</code>,
|
||||
<code>ContextMenuEntry</code>, <code>ContextMenuReadOnlyEntry</code>,
|
||||
and <code>ContextMenuSeparator</code>. The root directory has
|
||||
<code>show(x, y)</code> which renders lazily and positions itself in the
|
||||
correct viewport corner (right/left, below/above) based on available space.
|
||||
A <code>clearCloseUpwards()</code> walk was added to cancel all ancestor
|
||||
close timers when a submenu is entered, fixing the bug where moving the
|
||||
cursor from a parent menu into a child submenu closed the parent.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">src/components/context-menu/</span>
|
||||
<span class="tag">smart corner positioning</span>
|
||||
<span class="tag">clearCloseUpwards</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Tab Container ⋮ Menu</h3>
|
||||
<p>
|
||||
Replaced the split <code>⊟</code> button with a <code>⋮</code> menu button
|
||||
that opens a ContextMenu. Options: <em>Add ></em> (HTML Editor, File Tree),
|
||||
<em>Duplicate</em>, <em>Split</em>, <em>Close</em>. Duplicate was fixed by
|
||||
storing a <code>factory: () => HTMLElement</code> function in each
|
||||
<code>TabEntry</code> so new instances can be created without re-using the
|
||||
existing element. The <code>tab-container:add-panel</code> event is now
|
||||
handled in EditorShell's <code>setupSplitListener</code>.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">tab-container.ts</span>
|
||||
<span class="tag">TabEntry.factory</span>
|
||||
<span class="tag">tab-container:add-panel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>FileTree: Add File / Add Directory</h3>
|
||||
<p>
|
||||
Added <code>+F</code> and <code>+D</code> buttons to the FileTree header.
|
||||
Clicking either resolves the target directory from the current selection,
|
||||
finds a free name by scanning the tree, then shows an inline overlay with
|
||||
an input field, Create, and Cancel. Server-side endpoints
|
||||
<code>POST /:projectId/create-file</code> and
|
||||
<code>POST /:projectId/create-directory</code> were added to
|
||||
<code>server/routes/files.ts</code>. On success the component dispatches
|
||||
<code>Editor.onFilesChanged</code> so all FileTree instances refresh.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">file-tree-panel.ts</span>
|
||||
<span class="tag">server/routes/files.ts</span>
|
||||
<span class="tag">create-file / create-directory</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>EventSlot Refactor</h3>
|
||||
<p>
|
||||
Replaced all string-based <code>document.dispatchEvent</code> /
|
||||
<code>addEventListener</code> calls with typed
|
||||
<code>EventSlot<T></code> instances on the <code>Editor</code>
|
||||
singleton. Slots: <code>onDocumentOpened</code>,
|
||||
<code>onDocumentDirty</code>, <code>onDocumentSaved</code>,
|
||||
<code>onFilesChanged</code>. Components call
|
||||
<code>Editor.get().onDocumentOpened.addListener(…)</code> directly —
|
||||
no string keys, no silent mismatches.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">EventSlot<T></span>
|
||||
<span class="tag">src/editor/Editor.ts</span>
|
||||
<span class="tag">hard coupling</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Session Persistence</h3>
|
||||
<p>
|
||||
<code>session-file-store</code> was removed after it caused an
|
||||
<code>EPERM: operation not permitted, rename</code> error on Windows
|
||||
(atomic rename not permitted on the session file). Replaced with a custom
|
||||
<code>JsonSessionStore extends session.Store</code> that writes directly
|
||||
with <code>fs.writeFileSync</code> — no temporary file, no rename.
|
||||
Sessions are stored per-ID in <code>storage/sessions/</code>.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">server/sessionStore.ts</span>
|
||||
<span class="tag">JsonSessionStore</span>
|
||||
<span class="tag">Windows EPERM fix</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Tab Drag State Preservation</h3>
|
||||
<p>
|
||||
Dragging a tab between containers re-attaches the panel element to a new
|
||||
parent, triggering <code>connectedCallback</code> and re-running setup.
|
||||
Fixed with an <code>_initialized</code> guard on both FileTreePanel and
|
||||
HtmlEditorPanel. For HtmlEditorPanel the iframe reloads from stale
|
||||
<code>srcdoc</code> on re-attach, losing typed content; fixed by setting
|
||||
<code>_needsRestore = true</code> in <code>disconnectedCallback</code>
|
||||
so the next <code>onload</code> re-renders from the top of the undo stack
|
||||
instead of running normal setup.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">_initialized guard</span>
|
||||
<span class="tag">_needsRestore</span>
|
||||
<span class="tag">disconnectedCallback</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Localization System</h3>
|
||||
<p>
|
||||
A <code>localeGenerator.ts</code> walks <code>locales/en/</code> on server
|
||||
startup and generates <code>src/locales/Locales.ts</code> — a file of
|
||||
nested static classes mirroring the directory structure. File names become
|
||||
members with dots replaced by underscores and dashes converted to camelCase.
|
||||
A <code>LocaleManager</code> on the frontend fetches locale strings from
|
||||
<code>GET /api/locales/:locale/:path</code> and caches them by key.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">server/localeGenerator.ts</span>
|
||||
<span class="tag">src/locales/Locales.ts</span>
|
||||
<span class="tag">src/locales/LocaleManager.ts</span>
|
||||
<span class="tag">server/routes/locales.ts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Editor Panel Header Redesign</h3>
|
||||
<p>
|
||||
Panel headers now show an icon and the panel's dynamic name inside the
|
||||
<code>div.tc-tab</code> tab strip label, updated via a bubbling
|
||||
<code>panel:label-change</code> custom event that TabContainer listens for.
|
||||
FileTree shows <code>📁 dirname</code>; HtmlEditor shows
|
||||
<code>📄 filename</code>. The panel headers and toolbars were simplified
|
||||
to contain only action buttons, left-aligned.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">panel:label-change</span>
|
||||
<span class="tag">tc-tab label</span>
|
||||
<span class="tag">ftp-header</span>
|
||||
<span class="tag">hep-toolbar</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>FileTree Sub-Directory Root</h3>
|
||||
<p>
|
||||
Each FileTreePanel instance now has a <code>_rootPath</code> that can be
|
||||
changed independently of other instances. Double-clicking a directory label
|
||||
drills into it; a <code>[ .. ]</code> entry at the top navigates up one
|
||||
level. The tree is fetched in full each time and the subtree at
|
||||
<code>_rootPath</code> is sliced out client-side. The dirname shown in the
|
||||
tab label updates on every refresh.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">_rootPath</span>
|
||||
<span class="tag">dblclick to drill</span>
|
||||
<span class="tag">[ .. ] entry</span>
|
||||
<span class="tag">per-instance</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>HTML Editor: Init and Pin Buttons</h3>
|
||||
<p>
|
||||
<strong>Init</strong> inserts a Hello World HTML template (with a
|
||||
<code><page-content></code> root element) into the current document,
|
||||
pushing it onto the undo stack so it can be undone. Enabled only when a
|
||||
document is open. <strong>Pin</strong> is a toggle button on the left of the
|
||||
toolbar that prevents the <code>onDocumentOpened</code> listener from
|
||||
switching the editor to a different file when a file is clicked in the tree.
|
||||
Turns orange when active.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">hep-init</span>
|
||||
<span class="tag">hep-pin</span>
|
||||
<span class="tag">_pinned flag</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Window Resize Handling</h3>
|
||||
<p>
|
||||
A <code>ResizeObserver</code> on <code>.es-workspace</code> recalculates
|
||||
panel flex sizes proportionally when the window changes size. It only acts
|
||||
when panels have been explicitly sized by dragging (guarded by
|
||||
<code>c.style.flex</code> being non-empty), leaving the default CSS
|
||||
<code>flex: 1</code> layout untouched. Both the main three panels and any
|
||||
inner section splits within a panel are covered.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">ResizeObserver</span>
|
||||
<span class="tag">_redistributeFlex</span>
|
||||
<span class="tag">editor-shell.ts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Portrait Bar Moved into Top Header</h3>
|
||||
<p>
|
||||
The three portrait panel selector buttons (<code>⊟</code> <code>⊡</code>
|
||||
<code>⊞</code>) were moved from a separate full-width bar below the header
|
||||
into the header itself on the right side. They are hidden via CSS
|
||||
(<code>display:none</code>) in landscape and shown as a flex group when the
|
||||
<code>.portrait</code> class is active. The back link lost its "Projects"
|
||||
text label, keeping only the <code>←</code> arrow. The title gained
|
||||
<code>flex: 1</code> to push the buttons to the right edge.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">.es-portrait-btns</span>
|
||||
<span class="tag">es-header</span>
|
||||
<span class="tag">portrait CSS class</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Layout Persistence — Per User Per Device</h3>
|
||||
<p>
|
||||
Each browser gets a stable random device ID stored in
|
||||
<code>localStorage</code> under the key <code>roject:deviceId</code>.
|
||||
On load, EditorShell fetches the saved layout from
|
||||
<code>GET /api/layout?deviceId=…</code> and applies panel flex values and
|
||||
the last active portrait panel. After any panel resize or portrait switch,
|
||||
a debounced (800 ms) save fires to <code>PUT /api/layout</code>. Layout
|
||||
files are stored server-side at
|
||||
<code>storage/layouts/{userId}/{deviceId}.json</code>, making them
|
||||
per-user and per-device without any client-side cookie dependency.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">server/routes/layout.ts</span>
|
||||
<span class="tag">storage/layouts/</span>
|
||||
<span class="tag">localStorage deviceId</span>
|
||||
<span class="tag">debounced save</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Key Decisions</h2>
|
||||
|
||||
<div class="decision">
|
||||
<strong>EventSlot over string-based DOM events for all inter-component communication</strong>
|
||||
<p>
|
||||
String event names silently fail when mistyped. EventSlot is typed,
|
||||
directly coupled, and makes all listeners explicit and findable. No
|
||||
intermediate bus, no global event name registry.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>panel:label-change as a bubbling DOM event (not EventSlot)</strong>
|
||||
<p>
|
||||
Panel-to-container communication crosses a DOM boundary that EventSlot
|
||||
cannot bridge directly (the container doesn't hold a reference to the panel
|
||||
at construction time). A bubbling CustomEvent is the right mechanism here —
|
||||
the container listens once and matches the event target against its
|
||||
<code>tabs</code> array.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>FileTree root is per-instance, not global editor state</strong>
|
||||
<p>
|
||||
Two FileTree panels in two different tab containers should be able to show
|
||||
different subdirectories simultaneously. Storing <code>_rootPath</code> on
|
||||
the element instance (not on <code>Editor</code>) achieves this with no
|
||||
coordination overhead.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Layout stored server-side, device ID stored client-side</strong>
|
||||
<p>
|
||||
Storing layout in <code>localStorage</code> alone would lose it when the
|
||||
browser data is cleared and would not survive a device change. Storing it
|
||||
server-side keyed by a stable device ID gives persistence across browser
|
||||
resets while keeping it per-device as requested.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Direct writeFileSync in session store (no atomic rename)</strong>
|
||||
<p>
|
||||
<code>session-file-store</code> uses a write-to-temp-then-rename strategy
|
||||
that fails on Windows with EPERM when the target file is held open. Direct
|
||||
<code>writeFileSync</code> avoids the rename entirely at the cost of
|
||||
non-atomic writes, which is acceptable for session data.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Structural Changes</h2>
|
||||
<div class="card">
|
||||
<p>
|
||||
<code>src/editor/Editor.ts</code> — central singleton (moved from <code>src/components/EditorState.ts</code>)<br>
|
||||
<code>server/sessionStore.ts</code> — custom <code>JsonSessionStore</code>, replaces <code>session-file-store</code><br>
|
||||
<code>server/routes/layout.ts</code> — new: GET/PUT layout per user per device<br>
|
||||
<code>server/localeGenerator.ts</code> — new: generates <code>src/locales/Locales.ts</code> on startup<br>
|
||||
<code>server/routes/locales.ts</code> — new: serves locale files<br>
|
||||
<code>src/locales/LocaleManager.ts</code> — new: frontend locale fetcher with cache<br>
|
||||
<code>src/components/context-menu/</code> — new: ContextMenu class hierarchy<br>
|
||||
<code>storage/layouts/</code> — new: per-user per-device layout JSON files<br>
|
||||
<code>storage/sessions/</code> — session files (now written by custom store)
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Roject — session log — 5 July 2026
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
<script>var NAV_ROOT = '../../../../';</script>
|
||||
<script src="../../../../_assets_/nav-data.js"></script>
|
||||
<script src="../../../../_assets_/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
[ Locales ]
|
||||
All strings in the application need to be replaced by localized strings (not now, but on the long run).
|
||||
We already started to implement it, but we're still not using it.
|
||||
The idea is the following: The locales are stored in "locales" with "locales/en" being the English source
|
||||
of the application and are then generated. Each directory will be also a directory and has its own class that will be put into
|
||||
"src/locales/generated". So a "locales/en/commands" will be created as "src/locales/generated/commands/Commands.ts" and will
|
||||
contain all files of the source directory "locales/en/commands" as members.
|
||||
|
||||
The locales can have multiple formats:
|
||||
- .txt: Raw utf8 text
|
||||
- .html: Html text
|
||||
- .json: A flexible json format that has at least { value:string }
|
||||
|
||||
Also any of these locales can have a companion starting with the same name but ending with an .md
|
||||
- greetings.txt
|
||||
- greetings.text.md
|
||||
|
||||
This files are context files. They give some context for the localization.
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
[ Structural Update ]
|
||||
The app needs to slowly (step by step) use locales, commands and the undo manager.
|
||||
We also need to update the outline.
|
||||
|
||||
[ Locales ]
|
||||
All strings in the application need to be replaced by localized strings (not now, but on the long run).
|
||||
We already started to implement it, but we're still not using it.
|
||||
The idea is the following: The locales are stored in "locales" with "locales/en" being the English source
|
||||
of the application and are then generated. Each directory will be also a directory and has its own class that will be put into
|
||||
"src/locales/generated". So a "locales/en/commands" will be created as "src/locales/generated/commands/Commands.ts" and will
|
||||
contain all files of the source directory "locales/en/commands" as members.
|
||||
|
||||
|
||||
[ Commands ]
|
||||
We need to move all actions that can be done in the application to Commands which should be used from now on
|
||||
for most file/data changing operations. Commands only have one execute function. When needed they will create
|
||||
an undoable function inside the execute method. However, not all commands will have a need for undo/redo.
|
||||
|
||||
[ Undo Manager ]
|
||||
When possible ever file editor should take care of an undo/redo. When an undo/redo is needed the
|
||||
UndoManager "src/library-ts/browser/undo/UndoManager.ts" should be used. It can work with asynchronous UndoActions
|
||||
and can compress multiple actions to one.
|
||||
|
|
@ -0,0 +1,309 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Session Summary — 6 July 2026</title>
|
||||
<link rel="stylesheet" href="../../../../_assets_/styles.css">
|
||||
<link rel="stylesheet" href="../../../../_assets_/nav.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<header>
|
||||
<p class="date">Monday, 6 July 2026</p>
|
||||
<h1>Roject — Session Summary</h1>
|
||||
<p class="subtitle">
|
||||
File tree rename/delete, locale generator rewrite, locale documentation,
|
||||
doc/ renamed to workspace/, fixed navigation header system,
|
||||
Guides/Actions workspace restructure, and Update History action rewrite.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>What we built</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>File Tree: Rename and Delete</h3>
|
||||
<p>
|
||||
Right-clicking any file or directory label in the file tree now opens a
|
||||
context menu with <em>Rename…</em> and <em>Delete</em> entries.
|
||||
Rename shows an inline overlay (same pattern as Add File) with the current
|
||||
name pre-filled. Delete requires confirmation via
|
||||
<code><confirm-dialog></code> before proceeding.
|
||||
</p>
|
||||
<p style="margin-top:0.75rem">
|
||||
Two new server-side storage functions were added:
|
||||
<code>renameProjectEntry</code> (validates new name has no path separators,
|
||||
target does not already exist, result stays within project root) and
|
||||
<code>deleteProjectEntry</code> (uses <code>fs.rmSync</code> with
|
||||
<code>recursive: true</code>). Exposed as
|
||||
<code>POST /:projectId/rename</code> and <code>POST /:projectId/delete</code>
|
||||
in <code>server/routes/files.ts</code>.
|
||||
<code>confirm-dialog.css</code> and <code>confirm-dialog.js</code> were added
|
||||
to <code>public/editor.html</code>.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">file-tree-panel.ts</span>
|
||||
<span class="tag">server/storage.ts</span>
|
||||
<span class="tag">server/routes/files.ts</span>
|
||||
<span class="tag">confirm-dialog</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Locale Generator Rewrite</h3>
|
||||
<p>
|
||||
<code>server/localeGenerator.ts</code> was rewritten to generate one
|
||||
<code>.ts</code> file per source directory instead of a single monolithic
|
||||
<code>Locales.ts</code>. Output goes to <code>src/locales/generated/</code>.
|
||||
The root directory generates <code>Locales.ts</code>; each subdirectory
|
||||
generates a file named in PascalCase (e.g. <code>commands/</code> →
|
||||
<code>Commands.ts</code>).
|
||||
</p>
|
||||
<p style="margin-top:0.75rem">
|
||||
Naming rules: directory names → PascalCase class names; file names →
|
||||
camelCase member names, with all dots replaced by underscores first, then
|
||||
dashes converted to camelCase on the remaining segments. The extension is
|
||||
always kept as a suffix after an underscore. Example:
|
||||
<code>add-file.txt</code> → <code>addFile_txt</code>,
|
||||
<code>config.min.json</code> → <code>config_min_json</code>.
|
||||
Parent classes re-expose child classes as <code>static readonly</code>
|
||||
members, so paths like
|
||||
<code>Locales.Commands.FileTreeCommands.addFile_txt</code> work naturally.
|
||||
</p>
|
||||
<p style="margin-top:0.75rem">
|
||||
<code>LocaleManager.$</code> was changed from a static method to a static
|
||||
getter, so usage is <code>LocaleManager.$.get(…)</code> without parentheses.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">server/localeGenerator.ts</span>
|
||||
<span class="tag">src/locales/generated/</span>
|
||||
<span class="tag">LocaleManager.ts</span>
|
||||
<span class="tag">static get $</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Locale Documentation</h3>
|
||||
<p>
|
||||
A full reference guide was written at
|
||||
<code>workspace/guides/locales/index.html</code> covering: what the locale
|
||||
system is, how source files are structured under <code>locales/en/</code>,
|
||||
how the generator turns them into TypeScript, the naming conventions
|
||||
(PascalCase directories, camelCase+extension members), how to add a new
|
||||
locale file, and how to use it in code via
|
||||
<code>LocaleManager.$.get( Locales.X.Y.member_ext )</code>.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">workspace/guides/locales/index.html</span>
|
||||
<span class="tag">naming conventions</span>
|
||||
<span class="tag">usage examples</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>doc/ Renamed to workspace/</h3>
|
||||
<p>
|
||||
The documentation root was renamed from <code>doc/</code> to
|
||||
<code>workspace/</code>. Because the directory was open in the IDE,
|
||||
PowerShell <code>Rename-Item</code> and Bash <code>mv</code> both failed
|
||||
with Permission Denied. The workaround was to copy the tree with
|
||||
<code>robocopy</code> then delete the original with
|
||||
<code>Remove-Item -Recurse -Force</code>.
|
||||
<code>CLAUDE.md</code> was updated to point to
|
||||
<code>workspace/outline/index.html</code>.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">robocopy workaround</span>
|
||||
<span class="tag">CLAUDE.md</span>
|
||||
<span class="tag">workspace/</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Workspace Navigation Header</h3>
|
||||
<p>
|
||||
Every workspace HTML page now includes a fixed navigation header generated
|
||||
from a central sitemap. The system consists of three files in
|
||||
<code>workspace/_assets_/</code>:
|
||||
</p>
|
||||
<ul style="margin-top:0.75rem">
|
||||
<li><strong>nav-data.js</strong> — declares <code>var NAV_DATA</code>, a tree of
|
||||
<code>{ title, path, children? }</code> nodes mirroring all workspace pages.</li>
|
||||
<li><strong>nav.js</strong> — finds the current page in the tree and renders a
|
||||
three-row header: breadcrumb (ancestors), siblings row (peers at the same level),
|
||||
and children row (direct children). After inserting the <code><nav></code>,
|
||||
it sets <code>document.body.style.paddingTop</code> dynamically to prevent content
|
||||
hiding under the fixed bar.</li>
|
||||
<li><strong>nav.css</strong> — <code>.wsnav</code> is <code>position: fixed</code>
|
||||
spanning the full viewport width; <code>.wsnav-inner</code> is centered at 57 em
|
||||
to keep the content aligned with the page body.</li>
|
||||
</ul>
|
||||
<p style="margin-top:0.75rem">
|
||||
Each page sets <code>var NAV_ROOT</code> to its relative depth from
|
||||
<code>workspace/</code> so nav links resolve correctly regardless of nesting
|
||||
level. Day entries use <code>'../../../../'</code>; the outline uses
|
||||
<code>'../'</code>.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">workspace/_assets_/nav-data.js</span>
|
||||
<span class="tag">workspace/_assets_/nav.js</span>
|
||||
<span class="tag">workspace/_assets_/nav.css</span>
|
||||
<span class="tag">position: fixed</span>
|
||||
<span class="tag">NAV_ROOT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Workspace Restructure: Guides and Actions</h3>
|
||||
<p>
|
||||
The workspace was reorganised into four top-level sections: Outline, Guides,
|
||||
Actions, and History. <strong>Guides</strong> hold informational reference
|
||||
material (how to approach a type of work). <strong>Actions</strong> hold
|
||||
repeatable procedures that an agent can be told to execute.
|
||||
</p>
|
||||
<p style="margin-top:0.75rem">
|
||||
Pages created: <code>workspace/guides/index.html</code>,
|
||||
<code>workspace/guides/writing-typescript-code/index.html</code>,
|
||||
<code>workspace/guides/locales/index.html</code>,
|
||||
<code>workspace/actions/index.html</code>,
|
||||
<code>workspace/actions/update-history/index.html</code>,
|
||||
<code>workspace/actions/update-outline/index.html</code>.
|
||||
The outline index was updated with Guides and Actions summary cards.
|
||||
<code>nav-data.js</code> was updated to include both sections.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">workspace/guides/</span>
|
||||
<span class="tag">workspace/actions/</span>
|
||||
<span class="tag">nav-data.js</span>
|
||||
<span class="tag">outline/index.html</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<div class="card">
|
||||
<h3>Update History Action: Commit and Push</h3>
|
||||
<p>
|
||||
The Update History action was rewritten with two new behaviours. First,
|
||||
when no entry exists for today, the agent now always asks which day to use
|
||||
(a session may have started the previous day) and waits for an answer before
|
||||
continuing. Second, a Step 5 was added: before running any git command, the
|
||||
agent presents a confirmation prompt stating the exact date, the commit
|
||||
message (a one-liner summary), and the push target (<code>main</code>), then
|
||||
waits for the user to approve. After approval it runs
|
||||
<code>git add .</code>, <code>git commit -m "…"</code>, and
|
||||
<code>git push</code>. A push failure is reported to the user without any
|
||||
automatic recovery attempt. Same-day updates append new cards when there is
|
||||
enough new material, or adjust existing content in place for minor changes.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">workspace/actions/update-history/index.html</span>
|
||||
<span class="tag">git add . / commit / push</span>
|
||||
<span class="tag">confirmation prompt</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Key Decisions</h2>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Right-click context menu for rename/delete (not header buttons)</strong>
|
||||
<p>
|
||||
The file tree header already had Add File and Add Directory buttons.
|
||||
Adding more buttons would crowd the header. Right-click is the conventional
|
||||
gesture for per-item operations on file trees and uses the existing
|
||||
ContextMenu infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>One generated .ts file per locale directory</strong>
|
||||
<p>
|
||||
A single flat <code>Locales.ts</code> would grow unbounded and force a
|
||||
full regeneration for every locale change. Per-directory files allow
|
||||
TypeScript to cache unchanged files and make the generated output easier
|
||||
to read and trace back to source.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>All dots in file names become underscores before dash-to-camelCase</strong>
|
||||
<p>
|
||||
File names like <code>config.min.json</code> have structural dots that
|
||||
are not word separators in the same way dashes are. Replacing all dots
|
||||
with underscores first produces unambiguous member names
|
||||
(<code>config_min_json</code>) without any special-casing for extensions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>LocaleManager.$ as a static getter, not a method</strong>
|
||||
<p>
|
||||
<code>LocaleManager.$</code> is a singleton accessor. Callers should read
|
||||
it like a property, not invoke it like a factory. Removing the parentheses
|
||||
at every call site makes the access pattern clear and consistent.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Fixed nav with .wsnav-inner for centered content</strong>
|
||||
<p>
|
||||
<code>position: fixed</code> and <code>margin: auto</code> cannot coexist
|
||||
on the same element because fixed positioning takes the element out of
|
||||
normal flow. Splitting into an outer full-width <code>.wsnav</code> and
|
||||
an inner <code>.wsnav-inner</code> (57 em, margin auto) solves this cleanly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="decision">
|
||||
<strong>Guides for style/approach, Actions for repeatable procedures</strong>
|
||||
<p>
|
||||
"Guides" describes informational reference material that helps a contributor
|
||||
understand how to work in an area. "Actions" describes discrete, repeatable
|
||||
procedures that a human or agent can be told to execute by name — they have
|
||||
concrete steps, not just advice.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Structural Changes</h2>
|
||||
<div class="card">
|
||||
<p>
|
||||
<code>doc/</code> → <code>workspace/</code> — root rename<br>
|
||||
<code>workspace/_assets_/nav-data.js</code> — new: sitemap for nav system<br>
|
||||
<code>workspace/_assets_/nav.js</code> — new: nav renderer<br>
|
||||
<code>workspace/_assets_/nav.css</code> — new: fixed nav styles<br>
|
||||
<code>workspace/guides/</code> — new section<br>
|
||||
<code>workspace/guides/writing-typescript-code/index.html</code> — new<br>
|
||||
<code>workspace/guides/locales/index.html</code> — new<br>
|
||||
<code>workspace/actions/</code> — new section<br>
|
||||
<code>workspace/actions/update-history/index.html</code> — new<br>
|
||||
<code>workspace/actions/update-outline/index.html</code> — new<br>
|
||||
<code>server/storage.ts</code> — added <code>renameProjectEntry</code>, <code>deleteProjectEntry</code><br>
|
||||
<code>server/routes/files.ts</code> — added <code>POST /:projectId/rename</code>, <code>POST /:projectId/delete</code><br>
|
||||
<code>src/components/file-tree-panel/file-tree-panel.ts</code> — added <code>showItemMenu</code>, <code>startInlineRename</code>, <code>renameEntry</code>, <code>deleteEntry</code><br>
|
||||
<code>public/editor.html</code> — added confirm-dialog CSS and script<br>
|
||||
<code>server/localeGenerator.ts</code> — complete rewrite (per-directory output)<br>
|
||||
<code>src/locales/generated/</code> — new: generated locale classes<br>
|
||||
<code>src/locales/LocaleManager.ts</code> — <code>static $</code> changed to <code>static get $</code><br>
|
||||
<code>CLAUDE.md</code> — updated to point to <code>workspace/outline/index.html</code>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Roject — session log — 6 July 2026
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
<script>var NAV_ROOT = '../../../../';</script>
|
||||
<script src="../../../../_assets_/nav-data.js"></script>
|
||||
<script src="../../../../_assets_/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Session History — 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">Session History</p>
|
||||
<h1>Roject</h1>
|
||||
<p class="subtitle">Log of all working sessions, most recent first.</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>2026 — July</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3><a href="2026/07-July/06-Monday/index.html">Monday, 6 July 2026</a></h3>
|
||||
<p>File tree rename/delete (right-click context menu), locale generator rewrite (per-directory output, new naming conventions), locale documentation, doc/ renamed to workspace/, fixed navigation header system, Guides/Actions workspace restructure, Update History action rewrite (commit/push with confirmation).</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3><a href="2026/07-July/05-Sunday/index.html">Sunday, 5 July 2026</a></h3>
|
||||
<p>ContextMenu with smart positioning, EventSlot refactor, session persistence, tab drag fixes, panel header redesign, FileTree sub-root, Init/Pin buttons, resize handling, portrait bar in header, layout persistence per user per device.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3><a href="2026/07-July/04-Saturday/index.html">Saturday, 4 July 2026</a></h3>
|
||||
<p>Folder restructure (doc/ root), project documentation outline, coding guidelines, history index, stylesheet extensions, CLAUDE.md reduced to a pointer.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3><a href="2026/07-July/03-Friday/index.html">Friday, 3 July 2026</a></h3>
|
||||
<p>ES module refactor, EditorState extracted to its own module, shared library added as git submodule, TypeScript project references for browser and node library parts.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3><a href="2026/07-July/02-Thursday/index.html">Thursday, 2 July 2026</a></h3>
|
||||
<p>TypeScript conversion, UUID IDs, project file storage, full editor with 3-panel layout, tab containers with drag-and-drop, WYSIWYG HTML editor, file tree, confirm dialog.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3><a href="2026/07-July/01-Wednesday/index.html">Wednesday, 1 July 2026</a></h3>
|
||||
<p>Initial project setup: Node.js + Express backend, auth (register/login/logout), groups and projects with member management, JSON file storage.</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>
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Roject — Documentation Overview</title>
|
||||
<link rel="stylesheet" href="./_assets_/styles.css">
|
||||
<link rel="stylesheet" href="./_assets_/nav.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<header>
|
||||
<p class="date">Project Documentation</p>
|
||||
<h1 style="font-size: 300%;">Roject</h1>
|
||||
<p class="subtitle">For editing files in projects</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
|
||||
|
||||
<div class="card">
|
||||
<h3>Outline</h3>
|
||||
<p>
|
||||
Explains Roject's goals, features and future plans and actions that are used for
|
||||
the project.
|
||||
<br>
|
||||
|
||||
<a href="./outline/index.html">Read more about the outline</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>History</h3>
|
||||
<p>
|
||||
Per-day history that gives insights about the daily work.
|
||||
<br>
|
||||
|
||||
<a href="./history/index.html">Read more about the history</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
|
||||
<footer>
|
||||
Roject documentation
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
<script>var NAV_ROOT = './';</script>
|
||||
<script src="./_assets_/nav-data.js"></script>
|
||||
<script src="./_assets_/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Roject — Developer Documentation</title>
|
||||
<link rel="stylesheet" href="../_assets_/styles.css">
|
||||
<link rel="stylesheet" href="../_assets_/nav.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<header>
|
||||
<p class="date">Project Documentation</p>
|
||||
<h1 style="font-size: 300%;">Roject</h1>
|
||||
<p class="subtitle">Developer reference for human and agent contributors. Keep this file up to date as the project evolves.</p>
|
||||
</header>
|
||||
|
||||
<section>
|
||||
<h2>Project Outline</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>What it is</h3>
|
||||
<p>
|
||||
Roject is a self-hosted, browser-based CMS for creating, editing, and storing
|
||||
HTML documents with assets, organised into projects. It is designed for individual
|
||||
developers or small teams who want a lightweight authoring environment with no
|
||||
external database dependency.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>What exists now</h3>
|
||||
<p>
|
||||
User accounts with registration, login, logout, and account deletion.
|
||||
Groups and projects with member management (viewer / editor / admin roles).
|
||||
Each project gets a real directory on disk at <code>storage/<uuid>/root/</code>
|
||||
with a default <code>index.html</code> on creation.
|
||||
</p>
|
||||
<p style="margin-top:0.75rem">
|
||||
A full editor page (<code>/editor.html</code>) with a 3-panel resizable layout
|
||||
(Left / Center / Right). Each panel holds one or more sections side by side,
|
||||
each section holds a <code><tab-container></code>. Tabs are draggable
|
||||
between containers. The Left panel shows the file tree; the Center panel holds
|
||||
the WYSIWYG HTML editor (iframe, <code>contenteditable</code>,
|
||||
MutationObserver, undo/redo, Ctrl+S save). The Right panel is empty by default
|
||||
and receives dropped tabs.
|
||||
</p>
|
||||
<p style="margin-top:0.75rem">
|
||||
A reusable <code><confirm-dialog></code> component replaces browser
|
||||
<code>confirm()</code> for destructive actions. Currently wired up for project
|
||||
deletion only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>What still needs work</h3>
|
||||
<p>
|
||||
This is a loose reminder, not a fixed backlog. Things we know are missing or
|
||||
incomplete: a file manager inside the editor (create, rename, delete files and
|
||||
folders from the tree); the Right panel has no default content and relies on
|
||||
manual tab dragging to populate; portrait mode's secondary section switcher
|
||||
(when a panel has multiple side-by-side sections) is not yet wired up; the
|
||||
member list UI shows raw UUIDs instead of usernames; the group editor and
|
||||
account delete button still use the browser <code>confirm()</code> instead of
|
||||
the custom dialog; non-HTML files in the tree are visible but not openable;
|
||||
and there is no real-time multi-user collaboration yet.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Technical Implementation</h2>
|
||||
|
||||
<div class="card">
|
||||
<h3>Backend</h3>
|
||||
<p>
|
||||
Node.js + Express, TypeScript compiled on the fly with <code>ts-node</code>.
|
||||
No database — all data lives as JSON files in <code>data/</code>
|
||||
(auto-created on first run). Auth uses <code>express-session</code> +
|
||||
<code>bcryptjs</code>. All entity IDs are UUIDs via
|
||||
<code>crypto.randomUUID()</code> — no central counter, safe for parallel
|
||||
instances. Start the server with <code>npm start</code>.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">Node.js</span>
|
||||
<span class="tag">Express</span>
|
||||
<span class="tag">ts-node</span>
|
||||
<span class="tag">express-session</span>
|
||||
<span class="tag">bcryptjs</span>
|
||||
<span class="tag">UUID IDs</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Frontend</h3>
|
||||
<p>
|
||||
Vanilla HTML, raw CSS (no Tailwind, no framework). Every UI component is a
|
||||
custom element with its own <code>.ts</code> and <code>.css</code> file in
|
||||
<code>src/components/<name>/</code>. CSS uses the element tag as root
|
||||
selector with <code>display: block</code>. TypeScript compiles to
|
||||
<code>public/components/</code> via <code>tsconfig.client.json</code>
|
||||
(<code>module: ESNext</code>, <code>moduleResolution: bundler</code>, no
|
||||
bundler). HTML pages load components with
|
||||
<code><script type="module"></code>. Shared state uses a module-level
|
||||
singleton (<code>editor-state.ts</code>) rather than globals.
|
||||
Build with <code>npm run build</code>.
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">Web Components</span>
|
||||
<span class="tag">raw CSS</span>
|
||||
<span class="tag">module: ESNext</span>
|
||||
<span class="tag">no bundler</span>
|
||||
<span class="tag">tsc --build</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Shared Library</h3>
|
||||
<p>
|
||||
A personal TypeScript library lives as a git submodule at
|
||||
<code>src/library-ts/</code>. It has two parts: <code>browser/</code>
|
||||
(DOM-capable) and <code>node/</code> (Node.js only). The browser part is
|
||||
compiled separately via TypeScript project references
|
||||
(<code>src/library-ts/browser/tsconfig.roject.json</code>, <code>strict: false</code>)
|
||||
into <code>public/library-ts/browser/</code>. The node part is included by
|
||||
<code>tsconfig.ts-node.json</code> (extends server config, <code>strictNullChecks: false</code>).
|
||||
</p>
|
||||
<div class="tags">
|
||||
<span class="tag">git submodule</span>
|
||||
<span class="tag">src/library-ts/</span>
|
||||
<span class="tag">project references</span>
|
||||
<span class="tag">composite: true</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Locales</h3>
|
||||
<p>
|
||||
All user-visible strings are stored as plain files under <code>locales/en/</code>
|
||||
and accessed in code via <code>LocaleManager.$.get( Locales.X.Y.member_ext )</code>.
|
||||
Never hardcode a user-visible string directly in TypeScript.
|
||||
<br>
|
||||
<a href="locales/index.html">Read more about the locale system</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Guides</h3>
|
||||
<p>
|
||||
Reference guides for working in specific areas of the project.
|
||||
Read the relevant guide before starting a task in that area.
|
||||
<br>
|
||||
<a href="../guides/index.html">Browse all guides</a>
|
||||
</p>
|
||||
<ul style="margin-top:0.75rem">
|
||||
<li><strong>Writing code</strong> → <a href="../guides/writing-typescript-code/index.html">Writing TypeScript Code</a></li>
|
||||
<li><strong>Adding user-visible strings</strong> → <a href="../guides/locales/index.html">Locales</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Actions</h3>
|
||||
<p>
|
||||
Repeatable procedures for maintaining the project.
|
||||
<br>
|
||||
<a href="../actions/index.html">Browse all actions</a>
|
||||
</p>
|
||||
<ul style="margin-top:0.75rem">
|
||||
<li><strong>End of session</strong> → <a href="../actions/update-history/index.html">Update History</a>, <a href="../actions/update-outline/index.html">Update Outline</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
Roject — developer documentation
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
<script>var NAV_ROOT = '../';</script>
|
||||
<script src="../_assets_/nav-data.js"></script>
|
||||
<script src="../_assets_/nav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue