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:
@@ -1,9 +1,4 @@
|
|||||||
services:
|
x-env: &env
|
||||||
nanodrop:
|
|
||||||
build: .
|
|
||||||
ports:
|
|
||||||
- "${PORT:-3000}:3000"
|
|
||||||
environment:
|
|
||||||
PORT: "${PORT:-3000}"
|
PORT: "${PORT:-3000}"
|
||||||
HOST: "${HOST:-0.0.0.0}"
|
HOST: "${HOST:-0.0.0.0}"
|
||||||
JWT_SECRET: "${JWT_SECRET}"
|
JWT_SECRET: "${JWT_SECRET}"
|
||||||
@@ -15,9 +10,24 @@ services:
|
|||||||
BASE_URL: "${BASE_URL:-http://localhost:3000}"
|
BASE_URL: "${BASE_URL:-http://localhost:3000}"
|
||||||
COOKIE_SECURE: "${COOKIE_SECURE:-false}"
|
COOKIE_SECURE: "${COOKIE_SECURE:-false}"
|
||||||
TRUST_PROXY: "${TRUST_PROXY:-false}"
|
TRUST_PROXY: "${TRUST_PROXY:-false}"
|
||||||
|
|
||||||
|
services:
|
||||||
|
nanodrop:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "${PORT:-3000}:3000"
|
||||||
|
environment: { <<: *env }
|
||||||
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:
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user