web: account avatars, member /profile, framed admin panel
- an account can carry a picture: POST /api/auth/avatar (raw bytes, sniffed, replaces and unlinks the old file) and the public GET /api/users/:id/avatar. It rides wherever the account is named — the landing chip, the studio TopBar, the profile form. - new /profile page for members, sharing one Profile form (picture, email, password) with the admin drawer. - /admin is now one bordered frame whose left column is Profile / User account / Pictures / Close. Pictures lists every photo in the system with the slot that shows it; User account lists each account's name, email, picture and contribution count. - account control opens a menu: Admin page + Log out for an admin, Profile + Log out for a member.
This commit is contained in:
@@ -20,6 +20,11 @@ const UPLOAD_DIR = join(DATA_DIR, 'uploads');
|
||||
mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
export const photoPath = (file: string) => join(UPLOAD_DIR, file);
|
||||
|
||||
// Profile pictures, one per account, named the same way.
|
||||
const AVATAR_DIR = join(DATA_DIR, 'avatars');
|
||||
mkdirSync(AVATAR_DIR, { recursive: true });
|
||||
export const avatarPath = (file: string) => join(AVATAR_DIR, file);
|
||||
|
||||
export const db = new Database(join(DATA_DIR, 'recipescam.db'));
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
@@ -73,7 +78,16 @@ export const isPhotoSlot = (v: unknown): v is PhotoSlot =>
|
||||
}
|
||||
}
|
||||
|
||||
export type User = { id: number; email: string };
|
||||
// The avatar column arrived after the first accounts did, same as photos.slot.
|
||||
{
|
||||
const cols = db.prepare('PRAGMA table_info(users)').all() as { name: string }[];
|
||||
if (!cols.some((c) => c.name === 'avatar')) {
|
||||
db.exec(`ALTER TABLE users ADD COLUMN avatar TEXT`);
|
||||
}
|
||||
}
|
||||
|
||||
// `avatar` is the stored file name, or null for "no picture".
|
||||
export type User = { id: number; email: string; avatar: string | null };
|
||||
export type Recipe = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -109,7 +123,7 @@ export function createUser(email: string, password: string): User | null {
|
||||
const info = db
|
||||
.prepare('INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?)')
|
||||
.run(email, hashPassword(password), now());
|
||||
return { id: Number(info.lastInsertRowid), email };
|
||||
return { id: Number(info.lastInsertRowid), email, avatar: null };
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return null;
|
||||
throw err;
|
||||
@@ -118,12 +132,28 @@ export function createUser(email: string, password: string): User | null {
|
||||
|
||||
export function findUserByEmail(email: string): (User & { password_hash: string }) | undefined {
|
||||
return db
|
||||
.prepare('SELECT id, email, password_hash FROM users WHERE email = ?')
|
||||
.prepare('SELECT id, email, avatar, password_hash FROM users WHERE email = ?')
|
||||
.get(email) as (User & { password_hash: string }) | undefined;
|
||||
}
|
||||
|
||||
export function findUserById(id: number): User | undefined {
|
||||
return db.prepare('SELECT id, email FROM users WHERE id = ?').get(id) as User | undefined;
|
||||
return db.prepare('SELECT id, email, avatar FROM users WHERE id = ?').get(id) as User | undefined;
|
||||
}
|
||||
|
||||
// Swaps the picture and hands back the file it replaced, so the caller can
|
||||
// unlink it — the row is the only index of what is on disk.
|
||||
export function setUserAvatar(id: number, file: string): string | null {
|
||||
const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(id) as { avatar: string | null } | undefined;
|
||||
if (!row) return null;
|
||||
db.prepare('UPDATE users SET avatar = ? WHERE id = ?').run(file, id);
|
||||
return row.avatar;
|
||||
}
|
||||
|
||||
// Avatars are public by nature — they sit next to a name — so this is not
|
||||
// session-gated. It returns only the row's own file name, never a client path.
|
||||
export function userAvatar(id: number): string | undefined {
|
||||
const row = db.prepare('SELECT avatar FROM users WHERE id = ?').get(id) as { avatar: string | null } | undefined;
|
||||
return row?.avatar ?? undefined;
|
||||
}
|
||||
|
||||
export function createSession(userId: number): string {
|
||||
@@ -217,6 +247,38 @@ export function listPhotosWithOwner(): AdminPhoto[] {
|
||||
.all() as AdminPhoto[];
|
||||
}
|
||||
|
||||
// Admin listing: one row per account with how many photos it owns.
|
||||
export type AdminUser = { id: number; email: string; createdAt: string; photos: number; avatar: string | null };
|
||||
|
||||
export function listUsersWithCounts(): AdminUser[] {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT users.id AS id, users.email AS email, users.created_at AS createdAt,
|
||||
users.avatar AS avatar, COUNT(photos.id) AS photos
|
||||
FROM users LEFT JOIN photos ON photos.user_id = users.id
|
||||
GROUP BY users.id
|
||||
ORDER BY users.id`,
|
||||
)
|
||||
.all() as AdminUser[];
|
||||
}
|
||||
|
||||
// Profile edits. The email column is UNIQUE, so a taken address comes back as
|
||||
// false rather than a thrown constraint; the password uses the same hash the
|
||||
// sign-up path writes.
|
||||
export function updateUserEmail(id: number, email: string): boolean {
|
||||
try {
|
||||
db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email, id);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code === 'SQLITE_CONSTRAINT_UNIQUE') return false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export function setUserPassword(id: number, password: string): void {
|
||||
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hashPassword(password), id);
|
||||
}
|
||||
|
||||
export function countPhotos(userId: number): number {
|
||||
return (db.prepare('SELECT COUNT(*) AS n FROM photos WHERE user_id = ?').get(userId) as { n: number }).n;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user