mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 14:51:45 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00a7ac0341 | |||
| d30132197e | |||
| 9bf4220054 | |||
| 0d0ab5080c | |||
| 1084d40685 | |||
| 75ef928264 |
@@ -15,6 +15,7 @@ import ar from './translations/ar'
|
|||||||
import br from './translations/br'
|
import br from './translations/br'
|
||||||
import cs from './translations/cs'
|
import cs from './translations/cs'
|
||||||
import pl from './translations/pl'
|
import pl from './translations/pl'
|
||||||
|
import ja from './translations/ja'
|
||||||
import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
|
import { SUPPORTED_LANGUAGES, SupportedLanguageCode } from './supportedLanguages'
|
||||||
|
|
||||||
export { SUPPORTED_LANGUAGES }
|
export { SUPPORTED_LANGUAGES }
|
||||||
@@ -23,7 +24,7 @@ type TranslationStrings = Record<string, string | { name: string; category: stri
|
|||||||
|
|
||||||
// Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation.
|
// Keyed by SupportedLanguageCode so TypeScript enforces all languages have a translation.
|
||||||
const translations: Record<SupportedLanguageCode, TranslationStrings> = {
|
const translations: Record<SupportedLanguageCode, TranslationStrings> = {
|
||||||
de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl,
|
de, en, es, fr, hu, it, ru, zh, 'zh-TW': zhTw, nl, id, ar, br, cs, pl, ja,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
|
// Derived from SUPPORTED_LANGUAGES — add new languages there, not here.
|
||||||
@@ -38,7 +39,7 @@ export function getLocaleForLanguage(language: string): string {
|
|||||||
|
|
||||||
export function getIntlLanguage(language: string): string {
|
export function getIntlLanguage(language: string): string {
|
||||||
if (language === 'br') return 'pt-BR'
|
if (language === 'br') return 'pt-BR'
|
||||||
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id'].includes(language) ? language : 'en'
|
return ['de', 'es', 'fr', 'hu', 'it', 'ru', 'zh', 'zh-TW', 'nl', 'ar', 'cs', 'pl', 'id', 'ja' ].includes(language) ? language : 'en'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRtlLanguage(language: string): boolean {
|
export function isRtlLanguage(language: string): boolean {
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ export const SUPPORTED_LANGUAGES = [
|
|||||||
{ value: 'zh', label: '简体中文', locale: 'zh-CN' },
|
{ value: 'zh', label: '简体中文', locale: 'zh-CN' },
|
||||||
{ value: 'zh-TW', label: '繁體中文', locale: 'zh-TW' },
|
{ value: 'zh-TW', label: '繁體中文', locale: 'zh-TW' },
|
||||||
{ value: 'it', label: 'Italiano', locale: 'it-IT' },
|
{ value: 'it', label: 'Italiano', locale: 'it-IT' },
|
||||||
{ value: 'ar', label: 'العربية', locale: 'ar-SA' },
|
{ value: 'ar', label: 'العربية', locale: 'ar-SA' },
|
||||||
{ value: 'id', label: 'Bahasa Indonesia', locale: 'id-ID' },
|
{ value: 'id', label: 'Bahasa Indonesia', locale: 'id-ID' },
|
||||||
|
{ value: 'ja', label: '日本語', locale: 'ja-JP' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
|
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['value']
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -317,7 +317,7 @@ export default function JourneyPublicPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-5 pt-4 pb-5 cursor-pointer" onClick={() => setViewingEntry(entry)}>
|
<div className="px-5 pt-4 pb-5">
|
||||||
{/* Title (only when no single photo — photo has it in overlay) */}
|
{/* Title (only when no single photo — photo has it in overlay) */}
|
||||||
{photos.length !== 1 && entry.title && (
|
{photos.length !== 1 && entry.title && (
|
||||||
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white tracking-tight leading-snug mb-2">{entry.title}</h3>
|
<h3 className="text-[16px] font-semibold text-zinc-900 dark:text-white tracking-tight leading-snug mb-2">{entry.title}</h3>
|
||||||
@@ -448,7 +448,7 @@ export default function JourneyPublicPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px', overflow: 'hidden' }}>
|
<div className="relative text-center text-white" style={{ background: 'linear-gradient(135deg, #000 0%, #0f172a 50%, #1e293b 100%)', padding: '32px 20px 28px' }}>
|
||||||
{journey.cover_image && (
|
{journey.cover_image && (
|
||||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
|
<div style={{ position: 'absolute', inset: 0, backgroundImage: `url(/uploads/${journey.cover_image})`, backgroundSize: 'cover', backgroundPosition: 'center', opacity: 0.15 }} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
+2
-4
@@ -372,10 +372,8 @@ export function createApp(): express.Application {
|
|||||||
} else {
|
} else {
|
||||||
console.error('Unhandled error:', err);
|
console.error('Unhandled error:', err);
|
||||||
}
|
}
|
||||||
const status = err.statusCode || err.status || 500;
|
const status = err.statusCode || 500;
|
||||||
// Expose the message for client errors (4xx); keep 'Internal server error' for 5xx.
|
res.status(status).json({ error: 'Internal server error' });
|
||||||
const message = status < 500 ? err.message : 'Internal server error';
|
|
||||||
res.status(status).json({ error: message });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import * as svc from '../services/journeyService';
|
|||||||
import { db } from '../db/database';
|
import { db } from '../db/database';
|
||||||
import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
|
import { createOrUpdateJourneyShareLink, getJourneyShareLink, deleteJourneyShareLink, getPublicJourney } from '../services/journeyShareService';
|
||||||
import { uploadToImmich } from '../services/memories/immichService';
|
import { uploadToImmich } from '../services/memories/immichService';
|
||||||
import { getAllowedExtensions } from '../services/fileService';
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -26,26 +25,9 @@ const storage = multer.diskStorage({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageFilter: multer.Options['fileFilter'] = (_req, file, cb) => {
|
|
||||||
if (!file.mimetype.startsWith('image/') || file.mimetype.includes('svg')) {
|
|
||||||
const err: Error & { statusCode?: number } = new Error('Only image files are allowed');
|
|
||||||
err.statusCode = 400;
|
|
||||||
return cb(err);
|
|
||||||
}
|
|
||||||
const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
|
|
||||||
const allowed = getAllowedExtensions().split(',').map(e => e.trim().toLowerCase());
|
|
||||||
if (!allowed.includes('*') && !allowed.includes(ext)) {
|
|
||||||
const err: Error & { statusCode?: number } = new Error(`File type .${ext} is not allowed`);
|
|
||||||
err.statusCode = 400;
|
|
||||||
return cb(err);
|
|
||||||
}
|
|
||||||
cb(null, true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage,
|
storage,
|
||||||
limits: { fileSize: 20 * 1024 * 1024 },
|
limits: { fileSize: 20 * 1024 * 1024 },
|
||||||
fileFilter: imageFilter,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Static prefix routes (MUST come before /:id) ─────────────────────────
|
// ── Static prefix routes (MUST come before /:id) ─────────────────────────
|
||||||
|
|||||||
@@ -781,20 +781,22 @@ export function updatePhoto(photoId: number, userId: number, data: { caption?: s
|
|||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
if (!canEdit(row.journey_id, userId)) return null;
|
if (!canEdit(row.journey_id, userId)) return null;
|
||||||
|
|
||||||
// caption lives on the gallery row; sort_order lives on the junction table
|
const fields: string[] = [];
|
||||||
// (JP_SELECT reads jep.sort_order, so updating journey_photos.sort_order
|
const values: unknown[] = [];
|
||||||
// would not be reflected in the returned row).
|
if (data.caption !== undefined) { fields.push('caption = ?'); values.push(data.caption); }
|
||||||
if (data.caption !== undefined) {
|
if (data.sort_order !== undefined) { fields.push('sort_order = ?'); values.push(data.sort_order); }
|
||||||
db.prepare('UPDATE journey_photos SET caption = ? WHERE id = ?').run(data.caption, photoId);
|
if (!fields.length) {
|
||||||
}
|
// no-op: return some photo row for this gallery item (first entry link)
|
||||||
if (data.sort_order !== undefined) {
|
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE gp.id = ? LIMIT 1`).get(photoId) as JourneyPhoto | null;
|
||||||
db.prepare('UPDATE journey_entry_photos SET sort_order = ? WHERE journey_photo_id = ?').run(data.sort_order, photoId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
values.push(photoId);
|
||||||
|
db.prepare(`UPDATE journey_photos SET ${fields.join(', ')} WHERE id = ?`).run(...values);
|
||||||
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE gp.id = ? LIMIT 1`).get(photoId) as JourneyPhoto | null;
|
return db.prepare(`SELECT ${JP_SELECT} FROM ${JP_JOIN} WHERE gp.id = ? LIMIT 1`).get(photoId) as JourneyPhoto | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// deletePhoto: hard-delete (backwards compat name used by old route).
|
// deletePhoto: hard-delete (backwards compat name used by old route).
|
||||||
export function deletePhoto(photoId: number, userId: number): { id: number; photo_id: number; file_path?: string | null; journey_id: number } | null {
|
export function deletePhoto(photoId: number, userId: number): { photo_id: number; file_path?: string | null; journey_id: number } | null {
|
||||||
const row = db.prepare('SELECT id, journey_id, photo_id FROM journey_photos WHERE id = ?').get(photoId) as { id: number; journey_id: number; photo_id: number } | undefined;
|
const row = db.prepare('SELECT id, journey_id, photo_id FROM journey_photos WHERE id = ?').get(photoId) as { id: number; journey_id: number; photo_id: number } | undefined;
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
if (!canEdit(row.journey_id, userId)) return null;
|
if (!canEdit(row.journey_id, userId)) return null;
|
||||||
@@ -804,7 +806,7 @@ export function deletePhoto(photoId: number, userId: number): { id: number; phot
|
|||||||
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
|
db.prepare('DELETE FROM journey_photos WHERE id = ?').run(photoId);
|
||||||
deleteTrekPhotoIfOrphan(row.photo_id);
|
deleteTrekPhotoIfOrphan(row.photo_id);
|
||||||
|
|
||||||
return { id: row.id, photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, journey_id: row.journey_id };
|
return { photo_id: row.photo_id, file_path: trekRow?.file_path ?? null, journey_id: row.journey_id };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Contributors ─────────────────────────────────────────────────────────
|
// ── Contributors ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -27,17 +27,14 @@ export async function ensureLocalThumbnail(
|
|||||||
const meta = await sharp(thumbAbs).metadata()
|
const meta = await sharp(thumbAbs).metadata()
|
||||||
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
|
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
|
||||||
}
|
}
|
||||||
|
} catch { /* regenerate */ }
|
||||||
|
|
||||||
await fs.mkdir(path.dirname(thumbAbs), { recursive: true })
|
await fs.mkdir(path.dirname(thumbAbs), { recursive: true })
|
||||||
await sharp(originalAbs)
|
await sharp(originalAbs)
|
||||||
.rotate()
|
.rotate()
|
||||||
.resize({ width: THUMB_MAX, height: THUMB_MAX, fit: 'inside', withoutEnlargement: true })
|
.resize({ width: THUMB_MAX, height: THUMB_MAX, fit: 'inside', withoutEnlargement: true })
|
||||||
.webp({ quality: THUMB_QUALITY })
|
.webp({ quality: THUMB_QUALITY })
|
||||||
.toFile(thumbAbs)
|
.toFile(thumbAbs)
|
||||||
const meta = await sharp(thumbAbs).metadata()
|
const meta = await sharp(thumbAbs).metadata()
|
||||||
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
|
return { thumbnailRelPath: thumbRel, width: meta.width ?? 0, height: meta.height ?? 0 }
|
||||||
} catch {
|
|
||||||
// Unsupported format, corrupt file, etc. — fall back to original in caller.
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -649,7 +649,7 @@ describe('Link photo to entry', () => {
|
|||||||
.send({});
|
.send({});
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
expect(res.body.error).toBe('journey_photo_id required');
|
expect(res.body.error).toBe('photo_id required');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1325,10 +1325,9 @@ describe('Edge cases', () => {
|
|||||||
const result = deleteEntry(entry.id, user.id);
|
const result = deleteEntry(entry.id, user.id);
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
|
|
||||||
// Junction row must be gone (ON DELETE CASCADE from journey_entries).
|
// Photo should be deleted with the entry
|
||||||
// Gallery row (journey_photos) is preserved — photo may belong to other entries.
|
const deletedPhoto = testDb.prepare('SELECT * FROM journey_photos WHERE id = ?').get(photo!.id) as any;
|
||||||
const junctionRow = testDb.prepare('SELECT * FROM journey_entry_photos WHERE entry_id = ?').get(entry.id) as any;
|
expect(deletedPhoto).toBeUndefined();
|
||||||
expect(junctionRow).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('JOURNEY-SVC-082: updateJourney can set cover_gradient', () => {
|
it('JOURNEY-SVC-082: updateJourney can set cover_gradient', () => {
|
||||||
@@ -1396,12 +1395,17 @@ describe('Edge cases', () => {
|
|||||||
|
|
||||||
addTripToJourney(journey.id, trip.id, user.id);
|
addTripToJourney(journey.id, trip.id, user.id);
|
||||||
|
|
||||||
// Trip photos now go straight into the journey gallery (no wrapper entry).
|
// Should have a [Trip Photos] entry with the imported photo
|
||||||
|
const photoEntry = testDb.prepare(
|
||||||
|
"SELECT * FROM journey_entries WHERE journey_id = ? AND title = '[Trip Photos]'"
|
||||||
|
).get(journey.id) as any;
|
||||||
|
expect(photoEntry).toBeDefined();
|
||||||
|
|
||||||
const photos = testDb.prepare(`
|
const photos = testDb.prepare(`
|
||||||
SELECT jp.*, tkp.asset_id FROM journey_photos jp
|
SELECT jp.*, tkp.asset_id FROM journey_photos jp
|
||||||
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
JOIN trek_photos tkp ON tkp.id = jp.photo_id
|
||||||
WHERE jp.journey_id = ?
|
WHERE jp.entry_id = ?
|
||||||
`).all(journey.id);
|
`).all(photoEntry.id);
|
||||||
expect(photos.length).toBe(1);
|
expect(photos.length).toBe(1);
|
||||||
expect((photos[0] as any).asset_id).toBe('immich-photo-1');
|
expect((photos[0] as any).asset_id).toBe('immich-photo-1');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ afterAll(() => {
|
|||||||
|
|
||||||
// -- Helpers ------------------------------------------------------------------
|
// -- Helpers ------------------------------------------------------------------
|
||||||
|
|
||||||
/** Insert a trek_photos + journey_photos (gallery) + journey_entry_photos row and return the trek_photos id (used as photoId in public URLs). */
|
/** Insert a trek_photos + journey_photos row and return the trek_photos id (used as photoId in public URLs). */
|
||||||
function insertJourneyPhoto(
|
function insertJourneyPhoto(
|
||||||
entryId: number,
|
entryId: number,
|
||||||
opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
|
opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
|
||||||
@@ -70,24 +70,10 @@ function insertJourneyPhoto(
|
|||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
`).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
|
`).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
|
||||||
const trekId = trekResult.lastInsertRowid as number;
|
const trekId = trekResult.lastInsertRowid as number;
|
||||||
|
|
||||||
// Look up journey_id from entry so gallery row is keyed to the journey (not entry).
|
|
||||||
const entryRow = testDb.prepare('SELECT journey_id FROM journey_entries WHERE id = ?').get(entryId) as { journey_id: number };
|
|
||||||
const journeyId = entryRow.journey_id;
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
testDb.prepare(`
|
testDb.prepare(`
|
||||||
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, caption, sort_order, created_at)
|
INSERT INTO journey_photos (entry_id, photo_id, caption, sort_order, created_at)
|
||||||
VALUES (?, ?, NULL, 0, ?)
|
VALUES (?, ?, NULL, 0, ?)
|
||||||
`).run(journeyId, trekId, now);
|
`).run(entryId, trekId, Date.now());
|
||||||
|
|
||||||
const galleryRow = testDb.prepare('SELECT id FROM journey_photos WHERE journey_id = ? AND photo_id = ?').get(journeyId, trekId) as { id: number };
|
|
||||||
|
|
||||||
testDb.prepare(`
|
|
||||||
INSERT OR IGNORE INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at)
|
|
||||||
VALUES (?, ?, 0, ?)
|
|
||||||
`).run(entryId, galleryRow.id, now);
|
|
||||||
|
|
||||||
// Return trek_photos.id — this is p.photo_id in the public API response
|
// Return trek_photos.id — this is p.photo_id in the public API response
|
||||||
// and the value the client sends to /api/public/journey/:token/photos/:photoId/:kind
|
// and the value the client sends to /api/public/journey/:token/photos/:photoId/:kind
|
||||||
return trekId;
|
return trekId;
|
||||||
|
|||||||
Reference in New Issue
Block a user