Fix four bugs and add logo/branding polish

- docker-compose: add register-user service (profiles: [tools]) with YAML anchor to avoid env duplication
- src/index.ts: show localhost instead of 0.0.0.0 in startup message
- file-view: render <img> inline for image/* MIME types
- file-list: add Copy link button per row (requires baseUrl param)
- layout: add hideLogo option; file view page hides the logo
- style.css: remove uppercase from .logo (Nanodrop not NANODROP), add button.copy-link styles, add .file-view img styles, widen last td

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 16:18:34 -08:00
parent b5ea21d44c
commit 5a47ae938e
8 changed files with 108 additions and 25 deletions

View File

@@ -1,23 +1,33 @@
x-env: &env
PORT: "${PORT:-3000}"
HOST: "${HOST:-0.0.0.0}"
JWT_SECRET: "${JWT_SECRET}"
JWT_EXPIRY: "${JWT_EXPIRY:-7d}"
DB_PATH: "${DB_PATH:-./data/nanodrop.db}"
UPLOAD_DIR: "${UPLOAD_DIR:-./data/uploads}"
LOG_FILE: "${LOG_FILE:-./data/nanodrop.log}"
MAX_FILE_SIZE: "${MAX_FILE_SIZE:-104857600}"
BASE_URL: "${BASE_URL:-http://localhost:3000}"
COOKIE_SECURE: "${COOKIE_SECURE:-false}"
TRUST_PROXY: "${TRUST_PROXY:-false}"
services: services:
nanodrop: nanodrop:
build: . build: .
ports: ports:
- "${PORT:-3000}:3000" - "${PORT:-3000}:3000"
environment: environment: { <<: *env }
PORT: "${PORT:-3000}"
HOST: "${HOST:-0.0.0.0}"
JWT_SECRET: "${JWT_SECRET}"
JWT_EXPIRY: "${JWT_EXPIRY:-7d}"
DB_PATH: "${DB_PATH:-./data/nanodrop.db}"
UPLOAD_DIR: "${UPLOAD_DIR:-./data/uploads}"
LOG_FILE: "${LOG_FILE:-./data/nanodrop.log}"
MAX_FILE_SIZE: "${MAX_FILE_SIZE:-104857600}"
BASE_URL: "${BASE_URL:-http://localhost:3000}"
COOKIE_SECURE: "${COOKIE_SECURE:-false}"
TRUST_PROXY: "${TRUST_PROXY:-false}"
volumes: volumes:
- nanodrop-data:/app/data - nanodrop-data:/app/data
restart: unless-stopped restart: unless-stopped
register-user:
build: .
profiles: [tools]
entrypoint: ["node", "--import", "tsx", "src/cli/register-user.ts"]
environment: { <<: *env }
volumes:
- nanodrop-data:/app/data
volumes: volumes:
nanodrop-data: nanodrop-data:

View File

@@ -57,8 +57,7 @@ header {
.logo { .logo {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.08em; letter-spacing: 0.02em;
text-transform: uppercase;
text-decoration: none; text-decoration: none;
color: var(--black); color: var(--black);
} }
@@ -349,8 +348,26 @@ td a {
} }
td:last-child { td:last-child {
width: 80px; width: 160px;
text-align: right; text-align: right;
white-space: nowrap;
}
/* ── Copy Link Button ───────────────────────────────────── */
button.copy-link {
display: inline-block;
padding: 0.3rem 0.6rem;
font-family: var(--font);
font-size: 11px;
letter-spacing: 0.04em;
background: var(--white);
color: var(--black);
border: var(--border);
cursor: pointer;
margin-right: 0.4rem;
}
button.copy-link:hover {
background: var(--gray-100);
} }
/* ── File View ──────────────────────────────────────────── */ /* ── File View ──────────────────────────────────────────── */
@@ -376,6 +393,7 @@ td:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
.file-view img,
.file-view video, .file-view video,
.file-view audio { .file-view audio {
width: 100%; width: 100%;

View File

@@ -12,7 +12,8 @@ const app = createServer({ config, db });
try { try {
await app.listen({ port: config.port, host: config.host }); await app.listen({ port: config.port, host: config.host });
console.log(`Nanodrop running on http://${config.host}:${config.port}`); const displayHost = config.host === '0.0.0.0' ? 'localhost' : config.host;
console.log(`Nanodrop running on http://${displayHost}:${config.port}`);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
process.exit(1); process.exit(1);

View File

@@ -100,7 +100,7 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps
app.get('/files', { preHandler: requireAuth }, async (request, reply) => { app.get('/files', { preHandler: requireAuth }, async (request, reply) => {
const { sub: userId } = request.user as JwtPayload; const { sub: userId } = request.user as JwtPayload;
const files = getFilesByUserId(db, userId); const files = getFilesByUserId(db, userId);
reply.type('text/html').send(fileListPage(files)); reply.type('text/html').send(fileListPage(files, config.baseUrl));
}); });
// POST /files/:id/delete // POST /files/:id/delete

View File

@@ -1,21 +1,25 @@
import { layout, escHtml } from './layout.ts'; import { layout, escHtml } from './layout.ts';
import type { FileRow } from '../db/files.ts'; import type { FileRow } from '../db/files.ts';
export function fileListPage(files: FileRow[]): string { export function fileListPage(files: FileRow[], baseUrl: string): string {
const rows = files.length === 0 const rows = files.length === 0
? '<tr><td colspan="5">No files yet. <a href="/upload">Upload one.</a></td></tr>' ? '<tr><td colspan="5">No files yet. <a href="/upload">Upload one.</a></td></tr>'
: files.map((f) => ` : files.map((f) => {
const shareUrl = `${baseUrl}/f/${escHtml(f.id)}`;
return `
<tr> <tr>
<td><a href="/f/${escHtml(f.id)}">${escHtml(f.original_name)}</a></td> <td><a href="/f/${escHtml(f.id)}">${escHtml(f.original_name)}</a></td>
<td>${escHtml(f.mime_type)}</td> <td>${escHtml(f.mime_type)}</td>
<td>${formatBytes(f.size)}</td> <td>${formatBytes(f.size)}</td>
<td>${escHtml(f.created_at)}</td> <td>${escHtml(f.created_at)}</td>
<td> <td>
<button class="copy-link" onclick="navigator.clipboard.writeText('${shareUrl}')">Copy link</button>
<form method="POST" action="/files/${escHtml(f.id)}/delete" style="display:inline"> <form method="POST" action="/files/${escHtml(f.id)}/delete" style="display:inline">
<button type="submit" class="danger">Delete</button> <button type="submit" class="danger">Delete</button>
</form> </form>
</td> </td>
</tr>`).join(''); </tr>`;
}).join('');
return layout('My files', ` return layout('My files', `
<h1>My files</h1> <h1>My files</h1>

View File

@@ -11,7 +11,9 @@ export function fileViewPage(file: FileRow): string {
</div>`; </div>`;
let media = ''; let media = '';
if (file.mime_type.startsWith('video/')) { if (file.mime_type.startsWith('image/')) {
media = `<img src="${rawUrl}" alt="${safeName}">`;
} else if (file.mime_type.startsWith('video/')) {
media = `<video controls src="${rawUrl}" preload="metadata"></video>`; media = `<video controls src="${rawUrl}" preload="metadata"></video>`;
} else if (file.mime_type.startsWith('audio/')) { } else if (file.mime_type.startsWith('audio/')) {
media = `<audio controls src="${rawUrl}" preload="metadata"></audio>`; media = `<audio controls src="${rawUrl}" preload="metadata"></audio>`;
@@ -23,5 +25,5 @@ export function fileViewPage(file: FileRow): string {
${media} ${media}
${actions} ${actions}
</div> </div>
`); `, { hideLogo: true });
} }

View File

@@ -1,5 +1,5 @@
export function layout(title: string, body: string, opts: { authed?: boolean } = {}): string { export function layout(title: string, body: string, opts: { authed?: boolean; hideLogo?: boolean } = {}): string {
const { authed = false } = opts; const { authed = false, hideLogo = false } = opts;
const nav = authed const nav = authed
? `<nav> ? `<nav>
<a href="/upload">Upload</a> <a href="/upload">Upload</a>
@@ -20,7 +20,7 @@ export function layout(title: string, body: string, opts: { authed?: boolean } =
</head> </head>
<body> <body>
<header> <header>
<a href="/" class="logo">Nanodrop</a> ${hideLogo ? '' : '<a href="/" class="logo">Nanodrop</a>'}
${nav} ${nav}
</header> </header>
<main> <main>

View File

@@ -133,6 +133,54 @@ describe('GET /f/:id and GET /f/:id/raw', () => {
}); });
}); });
describe('GET /f/:id — image inline', () => {
let ctx: TestContext;
let fileId: string;
beforeEach(async () => {
ctx = await setup();
const token = await loginAs(ctx.app, 'alice', 'secret');
const uploadRes = await ctx.app.inject({
method: 'POST',
url: '/upload',
cookies: { token },
...buildMultipart({ file: { filename: 'photo.png', contentType: 'image/png', data: Buffer.from('fakepng') } }),
});
const match = uploadRes.body.match(/\/f\/([^/"]+)/);
fileId = match?.[1] ?? '';
});
afterEach(async () => { await ctx.app.close(); ctx.cleanup(); });
it('shows <img> tag for image files', async () => {
const res = await ctx.app.inject({ method: 'GET', url: `/f/${fileId}` });
expect(res.statusCode).toBe(200);
expect(res.body).toContain('<img');
});
});
describe('GET /files — copy link', () => {
let ctx: TestContext;
let token: string;
beforeEach(async () => {
ctx = await setup();
token = await loginAs(ctx.app, 'alice', 'secret');
await ctx.app.inject({
method: 'POST',
url: '/upload',
cookies: { token },
...buildMultipart({ file: { filename: 'test.txt', contentType: 'text/plain', data: Buffer.from('hi') } }),
});
});
afterEach(async () => { await ctx.app.close(); ctx.cleanup(); });
it('shows Copy link button for each file', async () => {
const res = await ctx.app.inject({ method: 'GET', url: '/files', cookies: { token } });
expect(res.statusCode).toBe(200);
expect(res.body).toContain('Copy link');
});
});
describe('POST /files/:id/delete', () => { describe('POST /files/:id/delete', () => {
let ctx: TestContext; let ctx: TestContext;
let token: string; let token: string;