diff --git a/apps/api/src/routes/steps.ts b/apps/api/src/routes/steps.ts index 6aa5d69..eda35de 100644 --- a/apps/api/src/routes/steps.ts +++ b/apps/api/src/routes/steps.ts @@ -16,8 +16,44 @@ steps.get('/', async (c) => { }, 400); } - const service = new StepService(c.env.DB); - const data = await service.list(itineraryId); + // Check if user is authenticated (edit mode) + const authHeader = c.req.header('Authorization'); + const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; + + let isEditMode = false; + if (token) { + const { verifyToken } = await import('../utils/jwt'); + const payload = await verifyToken(token, c.env.JWT_SECRET); + isEditMode = !!payload; + } + + const stepService = new StepService(c.env.DB); + const itineraryService = new ItineraryService(c.env.DB); + const itinerary = await itineraryService.get(itineraryId); + + if (itinerary?.secret_settings?.enabled) { + // Get current time in JST (UTC+9) because DB stores local time + const now = new Date(); + const jstOffset = 9 * 60 * 60 * 1000; + const jstTime = new Date(now.getTime() + jstOffset).toISOString().replace('Z', ''); + + const offsetMinutes = itinerary.secret_settings.offset_minutes || 60; + + // Filter steps based on time + // If isEditMode is true OR itinerary has no password, we DO NOT mask secrets (maskSecrets: false) + // But we still pass time/offset so is_hidden is calculated + const hasEditPermission = isEditMode || !itinerary.password; + + const data = await stepService.list(itineraryId, { + currentTime: jstTime, + offsetMinutes: offsetMinutes, + maskSecrets: !hasEditPermission + }); + return c.json({ success: true, data }); + } + + // Secret mode disabled: return all steps + const data = await stepService.list(itineraryId); return c.json({ success: true, data }); }); diff --git a/apps/api/src/services/itinerary.service.ts b/apps/api/src/services/itinerary.service.ts index 97a5b00..cea40a0 100644 --- a/apps/api/src/services/itinerary.service.ts +++ b/apps/api/src/services/itinerary.service.ts @@ -7,7 +7,12 @@ export class ItineraryService { async list(): Promise { const result = await this.db - .prepare('SELECT * FROM itineraries ORDER BY created_at DESC') + .prepare(` + SELECT i.*, s.enabled as secret_enabled, s.offset_minutes as secret_offset + FROM itineraries i + LEFT JOIN itinerary_secrets s ON i.id = s.itinerary_id + ORDER BY i.created_at DESC + `) .all(); return result.results ? result.results.map(row => this.mapToItinerary(row)) : []; @@ -15,7 +20,12 @@ export class ItineraryService { async get(id: string): Promise { const result = await this.db - .prepare('SELECT * FROM itineraries WHERE id = ?') + .prepare(` + SELECT i.*, s.enabled as secret_enabled, s.offset_minutes as secret_offset + FROM itineraries i + LEFT JOIN itinerary_secrets s ON i.id = s.itinerary_id + WHERE i.id = ? + `) .bind(id) .first(); @@ -32,15 +42,34 @@ export class ItineraryService { theme_id: input.theme_id || 'minimal', memo: input.memo ?? null, password: input.password ?? null, + secret_settings: input.secret_settings ? { + enabled: input.secret_settings.enabled, + offset_minutes: input.secret_settings.offset_minutes + } : null, created_at: now, updated_at: now, }; + // Insert into main table await this.db .prepare('INSERT INTO itineraries (id, title, theme_id, memo, password, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)') .bind(itinerary.id, itinerary.title, itinerary.theme_id, itinerary.memo, itinerary.password, itinerary.created_at, itinerary.updated_at) .run(); + // Insert into secrets table if settings exist + if (itinerary.secret_settings) { + await this.db + .prepare('INSERT INTO itinerary_secrets (itinerary_id, enabled, offset_minutes, created_at, updated_at) VALUES (?, ?, ?, ?, ?)') + .bind( + itinerary.id, + itinerary.secret_settings.enabled ? 1 : 0, + itinerary.secret_settings.offset_minutes, + now, + now + ) + .run(); + } + return itinerary; } @@ -50,7 +79,7 @@ export class ItineraryService { const now = getCurrentTimestamp(); const fields = ['updated_at = ?']; - const values = [now]; + const values: any[] = [now]; if (input.title !== undefined) { fields.push('title = ?'); @@ -77,10 +106,45 @@ export class ItineraryService { .run(); } + // Handle secret settings update + if (input.secret_settings !== undefined) { + if (input.secret_settings === null) { + // Remove settings + await this.db + .prepare('DELETE FROM itinerary_secrets WHERE itinerary_id = ?') + .bind(id) + .run(); + } else { + // Upsert settings + // Check if exists first (D1 doesn't support INSERT OR REPLACE nicely with timestamps preservation if we want that, but here we just overwrite) + // Actually, standard SQL UPSERT or just DELETE+INSERT or UPDATE/INSERT check. + // Let's try INSERT OR REPLACE + await this.db + .prepare(` + INSERT INTO itinerary_secrets (itinerary_id, enabled, offset_minutes, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(itinerary_id) DO UPDATE SET + enabled = excluded.enabled, + offset_minutes = excluded.offset_minutes, + updated_at = excluded.updated_at + `) + .bind( + id, + input.secret_settings.enabled ? 1 : 0, + input.secret_settings.offset_minutes, + now, + now + ) + .run(); + } + } + return await this.get(id); } async delete(id: string): Promise { + // Foreign key cascade should handle the secrets table, but let's be safe or rely on DB + // Since we defined ON DELETE CASCADE in migration, deleting from itineraries is enough. const result = await this.db .prepare('DELETE FROM itineraries WHERE id = ?') .bind(id) @@ -90,7 +154,7 @@ export class ItineraryService { } private mapToItinerary(row: any): Itinerary { - return { + const itinerary: Itinerary = { id: row.id, title: row.title, theme_id: row.theme_id, @@ -99,5 +163,14 @@ export class ItineraryService { created_at: row.created_at, updated_at: row.updated_at, }; + + if (row.secret_enabled !== null && row.secret_enabled !== undefined) { + itinerary.secret_settings = { + enabled: row.secret_enabled === 1, + offset_minutes: row.secret_offset + }; + } + + return itinerary; } } diff --git a/apps/api/src/services/step.service.ts b/apps/api/src/services/step.service.ts index c089a1a..9fffabf 100644 --- a/apps/api/src/services/step.service.ts +++ b/apps/api/src/services/step.service.ts @@ -5,13 +5,36 @@ import { generateId, getCurrentTimestamp } from '../utils'; export class StepService { constructor(private db: D1Database) {} - async list(itineraryId: string): Promise { + async list(itineraryId: string, options?: { currentTime?: string; offsetMinutes?: number; maskSecrets?: boolean }): Promise { + let query = 'SELECT * FROM steps WHERE itinerary_id = ?'; + const bindings: any[] = [itineraryId]; + + // If time filtering is enabled (secret mode) + if (options?.currentTime && options?.offsetMinutes !== undefined) { + // Calculate is_hidden_flag: 1 if (step_time > current_time + offset), else 0 + // We select all steps but mark them as hidden if they are in the future beyond the offset + query = ` + SELECT *, + (datetime(date || ' ' || time) > datetime(?, '+' || ? || ' minutes')) as is_hidden_flag + FROM steps + WHERE itinerary_id = ? + `; + + // Bindings order: currentTime, offsetMinutes, itineraryId + bindings.length = 0; + bindings.push(options.currentTime); + bindings.push(options.offsetMinutes.toString()); + bindings.push(itineraryId); + } + + query += ' ORDER BY date ASC, time ASC'; + const result = await this.db - .prepare('SELECT * FROM steps WHERE itinerary_id = ? ORDER BY date ASC, time ASC') - .bind(itineraryId) + .prepare(query) + .bind(...bindings) .all(); - return (result.results || []).map(row => this.mapToStep(row)); + return (result.results || []).map(row => this.mapToStep(row, options?.maskSecrets)); } async get(stepId: string): Promise { @@ -106,8 +129,8 @@ export class StepService { return result.success; } - private mapToStep(row: any): Step { - return { + private mapToStep(row: any, maskSecrets: boolean = true): Step { + const step: Step = { id: row.id, itinerary_id: row.itinerary_id, title: row.title, @@ -115,8 +138,17 @@ export class StepService { time: row.time, location: row.location, notes: row.notes, + is_hidden: !!row.is_hidden_flag, created_at: row.created_at, updated_at: row.updated_at, }; + + if (step.is_hidden && maskSecrets) { + step.title = '?????'; + step.location = null; + step.notes = null; + } + + return step; } } diff --git a/apps/web/src/lib/api/step.ts b/apps/web/src/lib/api/step.ts index a1bc263..694a6a7 100644 --- a/apps/web/src/lib/api/step.ts +++ b/apps/web/src/lib/api/step.ts @@ -3,7 +3,7 @@ import { apiClient } from './client'; export const stepApi = { list: (itineraryId: string) => - apiClient.get(`/steps?itinerary_id=${itineraryId}`), + apiClient.get(`/steps?itinerary_id=${itineraryId}`, itineraryId), get: (stepId: string) => apiClient.get(`/steps/${stepId}`), diff --git a/apps/web/src/lib/themes/standard-autumn/ItineraryView.svelte b/apps/web/src/lib/themes/standard-autumn/ItineraryView.svelte index 4d222f4..6b2c334 100644 --- a/apps/web/src/lib/themes/standard-autumn/ItineraryView.svelte +++ b/apps/web/src/lib/themes/standard-autumn/ItineraryView.svelte @@ -15,6 +15,10 @@ title?: string; theme_id?: string; memo?: string; + secret_settings?: { + enabled: boolean; + offset_minutes: number; + } | null; }) => Promise; onCreateStep?: (data: { title: string; @@ -63,6 +67,11 @@ let showSettingsMenu = $state(false); let selectedThemeId = $state(itinerary.theme_id || "standard-autumn"); + let secretModeEnabled = $state(itinerary.secret_settings?.enabled ?? false); + let secretModeOffset = $state( + itinerary.secret_settings?.offset_minutes ?? 60, + ); + let newStep = $state({ title: "", date: "", @@ -101,6 +110,12 @@ auth.setToken(itinerary.id, itinerary.title, token); } hasEditPermission = auth.hasEditPermission(itinerary.id); + + // Auto-activate edit mode if no password and no token yet + if (!hasEditPermission && !itinerary.password) { + attemptEditModeActivation(); + } + auth.updateAccessTime(itinerary.id, itinerary.title); }); @@ -272,6 +287,17 @@ await onUpdateItinerary({ theme_id: themeId }); } } + + async function handleSecretModeUpdate() { + if (onUpdateItinerary) { + await onUpdateItinerary({ + secret_settings: { + enabled: secretModeEnabled, + offset_minutes: secretModeOffset, + }, + }); + } + }
@@ -559,6 +585,56 @@ テーマを変更 + +
+ +
+ + + {#if secretModeEnabled} +
+ 表示開始: + +
+ {/if} +
{/if} {#if showThemeSelect} diff --git a/apps/web/src/lib/themes/standard-autumn/StepList.svelte b/apps/web/src/lib/themes/standard-autumn/StepList.svelte index 0b1637c..bd75441 100644 --- a/apps/web/src/lib/themes/standard-autumn/StepList.svelte +++ b/apps/web/src/lib/themes/standard-autumn/StepList.svelte @@ -298,8 +298,10 @@
{#if editingStepId === step.id} +

予定を編集

+
+ {:else if step.is_hidden && !hasEditPermission} +
+
+ Secret Event + + + +
+
+
+
+
+
{:else}
diff --git a/apps/web/src/lib/themes/standard-autumn/theme.css b/apps/web/src/lib/themes/standard-autumn/theme.css index d868f57..3cc369a 100644 --- a/apps/web/src/lib/themes/standard-autumn/theme.css +++ b/apps/web/src/lib/themes/standard-autumn/theme.css @@ -860,11 +860,110 @@ body { color: var(--standard-autumn-text); border-radius: 12px; font-family: inherit; + font-weight: 500; } .standard-autumn-settings-item:hover { - background: var(--standard-autumn-bg); - color: var(--standard-autumn-primary); + background: rgba(0, 0, 0, 0.03); +} + +/* Settings Menu Additions */ +.standard-autumn-settings-divider { + height: 1px; + background: var(--standard-autumn-line-color); + margin: 0.5rem 0; + opacity: 0.5; +} + +.standard-autumn-settings-group { + padding: 0.5rem 0; +} + +.standard-autumn-settings-toggle { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + cursor: pointer; + user-select: none; + border-radius: 8px; + transition: background 0.2s; +} + +.standard-autumn-settings-toggle:hover { + background: rgba(0, 0, 0, 0.03); +} + +.standard-autumn-settings-label-text { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.95rem; + color: var(--standard-autumn-text); + font-weight: 500; +} + +.standard-autumn-toggle-input { + display: none; +} + +.standard-autumn-toggle-slider { + position: relative; + width: 40px; + height: 22px; + background-color: #e0e0e0; + border-radius: 22px; + transition: background-color 0.3s; +} + +.standard-autumn-toggle-slider::before { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + background-color: white; + border-radius: 50%; + transition: transform 0.3s; + box-shadow: 0 1px 3px rgba(0,0,0,0.2); +} + +.standard-autumn-toggle-input:checked + .standard-autumn-toggle-slider { + background-color: var(--standard-autumn-primary); +} + +.standard-autumn-toggle-input:checked + .standard-autumn-toggle-slider::before { + transform: translateX(18px); +} + +.standard-autumn-settings-subitem { + padding: 0.5rem 1rem 0.5rem 2.5rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + animation: slideDown 0.2s ease-out; +} + +.standard-autumn-settings-sublabel { + font-size: 0.85rem; + color: var(--standard-autumn-text-light); +} + +.standard-autumn-settings-select { + padding: 0.3rem 0.5rem; + border-radius: 6px; + border: 1px solid var(--standard-autumn-border); + font-size: 0.85rem; + color: var(--standard-autumn-text); + background: #fff; + cursor: pointer; +} + +@keyframes slideDown { + from { opacity: 0; transform: translateY(-5px); } + to { opacity: 1; transform: translateY(0); } } /* Carousel Controls */ diff --git a/apps/web/src/lib/utils/isPwa.ts b/apps/web/src/lib/utils/isPwa.ts deleted file mode 100644 index f09f85d..0000000 --- a/apps/web/src/lib/utils/isPwa.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Detects if the app is running in PWA (Progressive Web App) mode. - * This is particularly useful for iPhone PWAs where localStorage is isolated - * from the browser. - * - * @returns true if running in standalone PWA mode, false otherwise - */ -export function isPwa(): boolean { - if (typeof window === 'undefined') { - return false; - } - - return window.matchMedia('(display-mode: standalone)').matches; -} diff --git a/apps/web/src/routes/+page.svelte b/apps/web/src/routes/+page.svelte index a65d6da..fc53f92 100644 --- a/apps/web/src/routes/+page.svelte +++ b/apps/web/src/routes/+page.svelte @@ -4,7 +4,6 @@ import { itineraryApi } from "$lib/api/itinerary"; import { auth } from "$lib/auth"; import { getAvailableThemes } from "$lib/themes"; - import { isPwa } from "$lib/utils/isPwa"; let title = $state(""); let password = $state(""); @@ -18,12 +17,10 @@ let activeTab = $state<"create" | "add">("create"); let url = $state(""); let urlError = $state(""); - let isPwaMode = $state(false); const themes = getAvailableThemes(); onMount(() => { - isPwaMode = true; setTimeout(() => { recentItineraries = auth.getRecentItineraries(); showRecent = true; @@ -135,29 +132,26 @@

旅のしおりを、サクッと作成

- - {#if isPwaMode} -
- - -
- {/if} +
+ + +
{#if activeTab === "create"} diff --git a/apps/web/src/routes/[id]/+page.svelte b/apps/web/src/routes/[id]/+page.svelte index 7f97c02..2cf4457 100644 --- a/apps/web/src/routes/[id]/+page.svelte +++ b/apps/web/src/routes/[id]/+page.svelte @@ -12,17 +12,48 @@ data.theme.ui.customColors?.background || "#f9fafb", ); - onMount(() => { + // Use local state for steps to allow client-side updates (e.g. unmasking secrets) + let steps = $state(data.steps); + + // Update steps when data changes (e.g. after invalidateAll) + $effect(() => { + steps = data.steps; + }); + + onMount(async () => { auth.updateAccessTime(data.itinerary.id, data.itinerary.title); document.body.style.backgroundColor = backgroundColor; document.documentElement.style.backgroundColor = backgroundColor; + // Check if we have edit permission and need to re-fetch steps to reveal secrets + const token = + auth.extractTokenFromUrl() || auth.getToken(data.itinerary.id); + if (token) { + // If we have a token, we might be in edit mode. + // If the initial load was SSR, steps might be masked. + // We should re-fetch to get the unmasked data. + // We can check if any step is hidden or just force re-fetch if secret mode is enabled. + if (data.itinerary.secret_settings?.enabled) { + try { + const unmaskedSteps = await stepApi.list(data.itinerary.id); + steps = unmaskedSteps; + } catch (e) { + console.error("Failed to re-fetch steps:", e); + } + } + } + return () => { document.body.style.backgroundColor = ""; document.documentElement.style.backgroundColor = ""; }; }); + // ... existing functions ... + + // Update the ItineraryView prop to use `steps` instead of `data.steps` + // We need to find where ItineraryView is used. + async function handleUpdateItinerary(updateData: { title?: string; theme_id?: string; @@ -100,7 +131,7 @@ {#key data.itinerary.theme_id} - + \ No newline at end of file diff --git a/apps/web/static/icons/icon-192.png b/apps/web/static/icons/icon-192.png index be37b2f..c11175a 100644 Binary files a/apps/web/static/icons/icon-192.png and b/apps/web/static/icons/icon-192.png differ diff --git a/apps/web/static/icons/icon-512.png b/apps/web/static/icons/icon-512.png index 8e70383..bf96eaf 100644 Binary files a/apps/web/static/icons/icon-512.png and b/apps/web/static/icons/icon-512.png differ diff --git a/migrations/0001_simple_schema.sql b/migrations/0001_simple_schema.sql index 909805d..55f2fb9 100644 --- a/migrations/0001_simple_schema.sql +++ b/migrations/0001_simple_schema.sql @@ -1,6 +1,3 @@ -DROP TABLE IF EXISTS steps; -DROP TABLE IF EXISTS itineraries; - CREATE TABLE IF NOT EXISTS itineraries ( id TEXT PRIMARY KEY, title TEXT NOT NULL, diff --git a/migrations/0003_add_secret_mode.sql b/migrations/0003_add_secret_mode.sql new file mode 100644 index 0000000..191aba8 --- /dev/null +++ b/migrations/0003_add_secret_mode.sql @@ -0,0 +1,11 @@ +-- Migration: Create itinerary_secrets table +-- Date: 2025-11-24 + +CREATE TABLE itinerary_secrets ( + itinerary_id TEXT PRIMARY KEY, + enabled BOOLEAN DEFAULT FALSE, + offset_minutes INTEGER DEFAULT 60, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (itinerary_id) REFERENCES itineraries(id) ON DELETE CASCADE +); diff --git a/packages/types/src/itinerary.ts b/packages/types/src/itinerary.ts index 762eb3d..b27e302 100644 --- a/packages/types/src/itinerary.ts +++ b/packages/types/src/itinerary.ts @@ -1,9 +1,15 @@ +export interface ItinerarySecretSettings { + enabled: boolean; + offset_minutes: number; +} + export interface Itinerary { id: string; title: string; theme_id: string; memo?: string | null; password?: string | null; + secret_settings?: ItinerarySecretSettings | null; created_at: string; updated_at: string; } @@ -13,6 +19,10 @@ export interface CreateItineraryInput { theme_id?: string; // オプション、デフォルト: 'minimal' memo?: string; password?: string; + secret_settings?: { + enabled: boolean; + offset_minutes: number; + }; } export interface UpdateItineraryInput { @@ -20,4 +30,8 @@ export interface UpdateItineraryInput { theme_id?: string; memo?: string; password?: string; + secret_settings?: { + enabled: boolean; + offset_minutes: number; + }; } diff --git a/packages/types/src/step.ts b/packages/types/src/step.ts index 548180c..3cd8a89 100644 --- a/packages/types/src/step.ts +++ b/packages/types/src/step.ts @@ -6,6 +6,7 @@ export interface Step { time: string; // 必須: HH:mm形式 location?: string | null; // オプション notes?: string | null; // オプション + is_hidden?: boolean; // シークレットモード用 created_at: string; updated_at: string; }