mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
chore: update repo url
This commit is contained in:
@@ -8,11 +8,11 @@ body:
|
||||
attributes:
|
||||
label: Pre-flight checklist
|
||||
options:
|
||||
- label: I have searched [existing issues](https://github.com/mauriceboe/TREK/issues) and this bug has not been reported yet
|
||||
- label: I have searched [existing issues](https://github.com/liketrek/TREK/issues) and this bug has not been reported yet
|
||||
required: true
|
||||
- label: I am running the latest available version of TREK
|
||||
required: true
|
||||
- label: I have read the [Troubleshooting guide](https://github.com/mauriceboe/TREK/wiki/Troubleshooting) and my issue is not covered there
|
||||
- label: I have read the [Troubleshooting guide](https://github.com/liketrek/TREK/wiki/Troubleshooting) and my issue is not covered there
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Documentation
|
||||
url: https://github.com/mauriceboe/TREK/wiki
|
||||
url: https://github.com/liketrek/TREK/wiki
|
||||
about: Check the docs before opening an issue
|
||||
- name: Feature Request
|
||||
url: https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests
|
||||
url: https://github.com/liketrek/TREK/discussions/new?category=feature-requests
|
||||
about: Suggest a new feature or improvement in Discussions
|
||||
- name: Questions & Help
|
||||
url: https://github.com/mauriceboe/TREK/discussions
|
||||
url: https://github.com/liketrek/TREK/discussions
|
||||
about: For questions and general help, use Discussions instead
|
||||
@@ -13,8 +13,8 @@
|
||||
- [ ] Documentation update
|
||||
|
||||
## Checklist
|
||||
- [ ] I have read the [Contributing Guidelines](https://github.com/mauriceboe/TREK/wiki/Contributing)
|
||||
- [ ] My branch is [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
|
||||
- [ ] I have read the [Contributing Guidelines](https://github.com/liketrek/TREK/wiki/Contributing)
|
||||
- [ ] My branch is [up to date with `dev`](https://github.com/liketrek/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date)
|
||||
- [ ] This PR targets the `dev` branch, not `main` *(wiki-only PRs are exempt)*
|
||||
- [ ] I have tested my changes locally
|
||||
- [ ] I have added/updated tests that prove my fix is effective or that my feature works
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
body: [
|
||||
'## Wrong place for feature requests',
|
||||
'',
|
||||
'Feature requests should be submitted in [Discussions](https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests), not as issues.',
|
||||
'Feature requests should be submitted in [Discussions](https://github.com/liketrek/TREK/discussions/new?category=feature-requests), not as issues.',
|
||||
'',
|
||||
'This issue has been closed. Feel free to re-submit your idea in the right place!',
|
||||
].join('\n'),
|
||||
|
||||
+3
-3
@@ -10,7 +10,7 @@ Thanks for your interest in contributing! Please read these guidelines before op
|
||||
4. **Target the `dev` branch** — All PRs must be opened against `dev`, not `main`. Exception: PRs that only modify files under `wiki/` may target any branch
|
||||
5. **Match the existing style** — No reformatting, no linter config changes, no "while I'm here" cleanups
|
||||
6. **Tests** — Your changes must include tests. The project maintains 80%+ coverage; PRs that drop it will be closed
|
||||
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/mauriceboe/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
|
||||
7. **Branch up to date** — Your branch must be [up to date with `dev`](https://github.com/liketrek/TREK/wiki/Development-environment#3-keep-your-fork-up-to-date) before submitting a PR
|
||||
|
||||
## Pull Requests
|
||||
|
||||
@@ -39,8 +39,8 @@ feat(budget): add CSV export for expenses
|
||||
|
||||
## Development Environment
|
||||
|
||||
See the [Developer Environment page](https://github.com/mauriceboe/TREK/wiki/Development-environment) for more information on setting up your development environment.
|
||||
See the [Developer Environment page](https://github.com/liketrek/TREK/wiki/Development-environment) for more information on setting up your development environment.
|
||||
|
||||
## More Details
|
||||
|
||||
See the [Contributing wiki page](https://github.com/mauriceboe/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
|
||||
See the [Contributing wiki page](https://github.com/liketrek/TREK/wiki/Contributing) for the full tech stack, architecture overview, and detailed guidelines.
|
||||
|
||||
@@ -31,9 +31,9 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
<a href="https://www.buymeacoffee.com/mauriceboe"><img alt="BMAC" src="https://img.shields.io/badge/BMAC-support-FFDD00?style=for-the-badge" /></a>
|
||||
<br />
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/license-AGPL_v3-6B7280?style=flat-square" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://github.com/liketrek/TREK/releases"><img alt="Latest Release" src="https://img.shields.io/github/v/release/mauriceboe/TREK?include_prereleases&style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://hub.docker.com/r/mauriceboe/trek"><img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/mauriceboe/trek?style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://github.com/mauriceboe/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/mauriceboe/TREK?style=flat-square&color=6B7280" /></a>
|
||||
<a href="https://github.com/liketrek/TREK"><img alt="Stars" src="https://img.shields.io/github/stars/mauriceboe/TREK?style=flat-square&color=6B7280" /></a>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
||||
|
||||
<div align="center">
|
||||
|
||||
<img src="https://github.com/mauriceboe/trek-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
|
||||
<img src="https://github.com/liketrek/TREK-media/releases/download/readme-assets/TREK1.gif" alt="TREK — 60-second tour" width="100%" />
|
||||
|
||||
</div>
|
||||
|
||||
@@ -280,7 +280,7 @@ helm repo update
|
||||
helm install trek trek/trek
|
||||
```
|
||||
|
||||
See [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts/README.md) for values.
|
||||
See [`charts/README.md`](https://github.com/liketrek/TREK/blob/main/charts/README.md) for values.
|
||||
|
||||
<h2 id="install-as-app-pwa">Install as App (PWA)</h2>
|
||||
|
||||
@@ -331,7 +331,7 @@ The script creates a timestamped DB backup before making changes and prompts for
|
||||
|
||||
For production, put TREK behind a TLS-terminating reverse proxy. TREK uses WebSockets for real-time sync, so the proxy **must** support WebSocket upgrades on `/ws`.
|
||||
|
||||
If you use the MCP addon, the proxy must also pass the `Mcp-Session-Id` header through in both directions on `/mcp` — Nginx and Caddy do this by default, but a proxy that strips it makes every tool call open a new session instead of reusing one. See the [Reverse Proxy wiki page](https://github.com/mauriceboe/TREK/wiki/Reverse-Proxy) for details.
|
||||
If you use the MCP addon, the proxy must also pass the `Mcp-Session-Id` header through in both directions on `/mcp` — Nginx and Caddy do this by default, but a proxy that strips it makes every tool call open a new session instead of reusing one. See the [Reverse Proxy wiki page](https://github.com/liketrek/TREK/wiki/Reverse-Proxy) for details.
|
||||
|
||||
<details>
|
||||
<summary>Nginx</summary>
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
<CommunityApplications>
|
||||
<Profile>TREK is a self-hosted, real-time collaborative travel planner. Plan trips together with interactive maps, budgets, bookings, packing lists, day-by-day itineraries and file management — every change syncs instantly across everyone in your group. Includes OIDC/SSO, TOTP MFA, dark mode, PWA support, multi-language UI and a modular addon system (Vacay, Atlas, Collab, Budget, Packing, Journey). Maintained by mauriceboe — support and bug reports via GitHub Issues.</Profile>
|
||||
<Icon>https://raw.githubusercontent.com/mauriceboe/TREK/main/docs/trek-icon.png</Icon>
|
||||
<WebPage>https://github.com/mauriceboe/TREK</WebPage>
|
||||
<Forum>https://github.com/mauriceboe/TREK/issues</Forum>
|
||||
<WebPage>https://github.com/liketrek/TREK</WebPage>
|
||||
<Forum>https://github.com/liketrek/TREK/issues</Forum>
|
||||
<DonateLink>https://ko-fi.com/mauriceboe</DonateLink>
|
||||
<DonateText>Support TREK development</DonateText>
|
||||
</CommunityApplications>
|
||||
|
||||
@@ -1,146 +1,187 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee, Bug, Lightbulb, BookOpen } from 'lucide-react'
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||
import apiClient from '../../api/client'
|
||||
import {
|
||||
BookOpen,
|
||||
Bug,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Coffee,
|
||||
ExternalLink,
|
||||
Heart,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Tag,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import apiClient from '../../api/client';
|
||||
import { getLocaleForLanguage, useTranslation } from '../../i18n';
|
||||
|
||||
const REPO = 'mauriceboe/TREK'
|
||||
const PER_PAGE = 10
|
||||
const REPO = 'mauriceboe/TREK';
|
||||
const PER_PAGE = 10;
|
||||
|
||||
interface GithubRelease {
|
||||
id: number
|
||||
prerelease: boolean
|
||||
tag_name: string
|
||||
name: string | null
|
||||
body: string | null
|
||||
published_at: string | null
|
||||
created_at: string
|
||||
author: { login: string } | null
|
||||
[key: string]: unknown
|
||||
id: number;
|
||||
prerelease: boolean;
|
||||
tag_name: string;
|
||||
name: string | null;
|
||||
body: string | null;
|
||||
published_at: string | null;
|
||||
created_at: string;
|
||||
author: { login: string } | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: boolean }) {
|
||||
const { t, language } = useTranslation()
|
||||
const [releases, setReleases] = useState<GithubRelease[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({})
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const { t, language } = useTranslation();
|
||||
const [releases, setReleases] = useState<GithubRelease[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
const fetchReleases = async (pageNum = 1, append = false) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
|
||||
const data = Array.isArray(res.data) ? res.data : []
|
||||
setReleases(prev => append ? [...prev, ...data] : data)
|
||||
setHasMore(data.length === PER_PAGE)
|
||||
const res = await apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } });
|
||||
const data = Array.isArray(res.data) ? res.data : [];
|
||||
setReleases((prev) => (append ? [...prev, ...data] : data));
|
||||
setHasMore(data.length === PER_PAGE);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetchReleases(1).finally(() => setLoading(false))
|
||||
}, [])
|
||||
setLoading(true);
|
||||
fetchReleases(1).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
const next = page + 1
|
||||
setLoadingMore(true)
|
||||
await fetchReleases(next, true)
|
||||
setPage(next)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
const next = page + 1;
|
||||
setLoadingMore(true);
|
||||
await fetchReleases(next, true);
|
||||
setPage(next);
|
||||
setLoadingMore(false);
|
||||
};
|
||||
|
||||
const toggleExpand = (id) => {
|
||||
setExpanded(prev => ({ ...prev, [id]: !prev[id] }))
|
||||
}
|
||||
setExpanded((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString(getLocaleForLanguage(language), { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
};
|
||||
|
||||
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
|
||||
const renderBody = (body) => {
|
||||
if (!body) return null
|
||||
const lines = body.split('\n')
|
||||
const elements = []
|
||||
let listItems = []
|
||||
if (!body) return null;
|
||||
const lines = body.split('\n');
|
||||
const elements = [];
|
||||
let listItems = [];
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length > 0) {
|
||||
elements.push(
|
||||
<ul key={`ul-${elements.length}`} className="space-y-1 my-2">
|
||||
<ul key={`ul-${elements.length}`} className="my-2 space-y-1">
|
||||
{listItems.map((item, i) => (
|
||||
<li key={i} className="flex gap-2 text-xs text-content-muted">
|
||||
<span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--text-faint)' }} />
|
||||
<span
|
||||
className="mt-1.5 h-1 w-1 flex-shrink-0 rounded-full"
|
||||
style={{ background: 'var(--text-faint)' }}
|
||||
/>
|
||||
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
listItems = []
|
||||
);
|
||||
listItems = [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const escapeHtml = (str) => str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
const escapeHtml = (str) =>
|
||||
str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
const inlineFormat = (text) => {
|
||||
return escapeHtml(text)
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
|
||||
.replace(
|
||||
/`(.+?)`/g,
|
||||
'<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>'
|
||||
)
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, url) => {
|
||||
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#'
|
||||
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`
|
||||
})
|
||||
}
|
||||
const safeUrl = url.startsWith('http://') || url.startsWith('https://') ? url : '#';
|
||||
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">${label}</a>`;
|
||||
});
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) { flushList(); continue }
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
flushList();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('### ')) {
|
||||
flushList()
|
||||
flushList();
|
||||
elements.push(
|
||||
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1 text-content">
|
||||
<h4 key={elements.length} className="mb-1 mt-3 text-xs font-semibold text-content">
|
||||
{trimmed.slice(4)}
|
||||
</h4>
|
||||
)
|
||||
);
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
flushList()
|
||||
flushList();
|
||||
elements.push(
|
||||
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1 text-content">
|
||||
<h3 key={elements.length} className="mb-1 mt-3 text-sm font-semibold text-content">
|
||||
{trimmed.slice(3)}
|
||||
</h3>
|
||||
)
|
||||
);
|
||||
} else if (/^[-*] /.test(trimmed)) {
|
||||
listItems.push(trimmed.slice(2))
|
||||
listItems.push(trimmed.slice(2));
|
||||
} else {
|
||||
flushList()
|
||||
flushList();
|
||||
elements.push(
|
||||
<p key={elements.length} className="text-xs my-1 text-content-muted"
|
||||
<p
|
||||
key={elements.length}
|
||||
className="my-1 text-xs text-content-muted"
|
||||
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
flushList()
|
||||
return elements
|
||||
}
|
||||
flushList();
|
||||
return elements;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Support cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<a
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ff5e5b';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#ff5e5b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<div
|
||||
className="bg-[#ff5e5b15]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Coffee size={20} className="text-[#ff5e5b]" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -153,11 +194,28 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
href="https://buymeacoffee.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ffdd00';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#ffdd0015]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<div
|
||||
className="bg-[#ffdd0015]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Heart size={20} className="text-[#ffdd00]" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -170,12 +228,31 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
href="https://discord.gg/NhZBDSd4qW"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#5865F2';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#5865F215]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
<div
|
||||
className="bg-[#5865F215]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">Discord</div>
|
||||
@@ -185,16 +262,33 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
||||
href="https://github.com/liketrek/TREK/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ef4444';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#ef444415]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<div
|
||||
className="bg-[#ef444415]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Bug size={20} className="text-[#ef4444]" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -204,14 +298,31 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||
href="https://github.com/liketrek/TREK/discussions/new?category=feature-requests"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#f59e0b';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#f59e0b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<div
|
||||
className="bg-[#f59e0b15]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Lightbulb size={20} className="text-[#f59e0b]" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -221,14 +332,31 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/wiki"
|
||||
href="https://github.com/liketrek/TREK/wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#6366f1';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#6366f115]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<div
|
||||
className="bg-[#6366f115]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<BookOpen size={20} className="text-[#6366f1]" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -241,137 +369,134 @@ export default function GitHubPanel({ isPrerelease = false }: { isPrerelease?: b
|
||||
|
||||
{/* Loading / Error / Releases */}
|
||||
{loading ? (
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="p-8 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-content-muted" />
|
||||
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-content-muted" />
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
|
||||
<div className="p-6 text-center">
|
||||
<p className="text-sm text-content-muted">{t('admin.github.error')}</p>
|
||||
<p className="text-xs mt-1 text-content-faint">{error}</p>
|
||||
<p className="mt-1 text-xs text-content-faint">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border overflow-hidden bg-surface-card border-edge">
|
||||
<div className="px-5 py-4 border-b flex items-center justify-between border-edge-secondary">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t('admin.github.title')}</h2>
|
||||
<p className="text-xs mt-0.5 text-content-faint">{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
|
||||
<div className="overflow-hidden rounded-xl border border-edge bg-surface-card">
|
||||
<div className="flex items-center justify-between border-b border-edge-secondary px-5 py-4">
|
||||
<div>
|
||||
<h2 className="font-semibold text-content">{t('admin.github.title')}</h2>
|
||||
<p className="mt-0.5 text-xs text-content-faint">{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
|
||||
</div>
|
||||
<a
|
||||
href={`https://github.com/${REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-surface-secondary px-3 py-1.5 text-xs font-medium text-content-muted transition-colors"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
href={`https://github.com/${REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="px-5 py-4">
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
|
||||
{/* Timeline */}
|
||||
<div className="px-5 py-4">
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div
|
||||
className="absolute bottom-3 left-[11px] top-3 w-px"
|
||||
style={{ background: 'var(--border-primary)' }}
|
||||
/>
|
||||
|
||||
<div className="space-y-0">
|
||||
{(isPrerelease ? releases : releases.filter(r => !r.prerelease)).map((release, idx) => {
|
||||
const isLatest = idx === 0
|
||||
const isExpanded = expanded[release.id]
|
||||
<div className="space-y-0">
|
||||
{(isPrerelease ? releases : releases.filter((r) => !r.prerelease)).map((release, idx) => {
|
||||
const isLatest = idx === 0;
|
||||
const isExpanded = expanded[release.id];
|
||||
|
||||
return (
|
||||
<div key={release.id} className="relative pl-8 pb-5">
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
className="absolute left-0 top-1 w-[23px] h-[23px] rounded-full flex items-center justify-center border-2"
|
||||
style={{
|
||||
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
|
||||
</div>
|
||||
|
||||
{/* Release content */}
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-content">
|
||||
{release.tag_name}
|
||||
</span>
|
||||
{isLatest && (
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[rgba(34,197,94,0.12)] text-[#16a34a]">
|
||||
{t('admin.github.latest')}
|
||||
</span>
|
||||
)}
|
||||
{release.prerelease && (
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-[rgba(245,158,11,0.12)] text-[#d97706]">
|
||||
{t('admin.github.prerelease')}
|
||||
</span>
|
||||
)}
|
||||
return (
|
||||
<div key={release.id} className="relative pb-5 pl-8">
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
className="absolute left-0 top-1 flex h-[23px] w-[23px] items-center justify-center rounded-full border-2"
|
||||
style={{
|
||||
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
|
||||
</div>
|
||||
|
||||
{release.name && release.name !== release.tag_name && (
|
||||
<p className="text-xs font-medium mt-0.5 text-content-muted">
|
||||
{release.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="flex items-center gap-1 text-[11px] text-content-faint">
|
||||
<Calendar size={10} />
|
||||
{formatDate(release.published_at || release.created_at)}
|
||||
</span>
|
||||
{release.author && (
|
||||
<span className="text-[11px] text-content-faint">
|
||||
{t('admin.github.by')} {release.author.login}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable body */}
|
||||
{release.body && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleExpand(release.id)}
|
||||
className="flex items-center gap-1 text-[11px] font-medium transition-colors text-content-muted"
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 p-3 rounded-lg bg-surface-secondary">
|
||||
{renderBody(release.body)}
|
||||
</div>
|
||||
{/* Release content */}
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-content">{release.tag_name}</span>
|
||||
{isLatest && (
|
||||
<span className="rounded-full bg-[rgba(34,197,94,0.12)] px-2 py-0.5 text-[10px] font-semibold text-[#16a34a]">
|
||||
{t('admin.github.latest')}
|
||||
</span>
|
||||
)}
|
||||
{release.prerelease && (
|
||||
<span className="rounded-full bg-[rgba(245,158,11,0.12)] px-2 py-0.5 text-[10px] font-semibold text-[#d97706]">
|
||||
{t('admin.github.prerelease')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && (
|
||||
<div className="text-center pt-2">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors bg-surface-secondary text-content-muted"
|
||||
>
|
||||
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
|
||||
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||
</button>
|
||||
{release.name && release.name !== release.tag_name && (
|
||||
<p className="mt-0.5 text-xs font-medium text-content-muted">{release.name}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-1 flex items-center gap-3">
|
||||
<span className="flex items-center gap-1 text-[11px] text-content-faint">
|
||||
<Calendar size={10} />
|
||||
{formatDate(release.published_at || release.created_at)}
|
||||
</span>
|
||||
{release.author && (
|
||||
<span className="text-[11px] text-content-faint">
|
||||
{t('admin.github.by')} {release.author.login}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable body */}
|
||||
{release.body && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleExpand(release.id)}
|
||||
className="flex items-center gap-1 text-[11px] font-medium text-content-muted transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 rounded-lg bg-surface-secondary p-3">{renderBody(release.body)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && (
|
||||
<div className="pt-2 text-center">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-surface-secondary px-4 py-2 text-xs font-medium text-content-muted transition-colors"
|
||||
>
|
||||
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
|
||||
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import DemoBanner from './DemoBanner';
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('DemoBanner', () => {
|
||||
it('self-host link points to GitHub', () => {
|
||||
render(<DemoBanner />);
|
||||
const link = screen.getByText('self-host it').closest('a')!;
|
||||
expect(link).toHaveAttribute('href', 'https://github.com/mauriceboe/TREK');
|
||||
expect(link).toHaveAttribute('href', 'https://github.com/liketrek/TREK');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,24 +1,40 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Info, Github, Shield, Key, Users, Database, Upload, Clock, Puzzle, CalendarDays, Globe, ArrowRightLeft, Map, Briefcase, ListChecks, Wallet, FileText, Plane } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
Database,
|
||||
FileText,
|
||||
Github,
|
||||
Globe,
|
||||
Key,
|
||||
ListChecks,
|
||||
Map,
|
||||
Puzzle,
|
||||
Shield,
|
||||
Upload,
|
||||
Users,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
interface DemoTexts {
|
||||
titleBefore: string
|
||||
titleAfter: string
|
||||
title: string
|
||||
description: string
|
||||
resetIn: string
|
||||
minutes: string
|
||||
uploadNote: string
|
||||
fullVersionTitle: string
|
||||
features: string[]
|
||||
addonsTitle: string
|
||||
addons: [string, string][]
|
||||
whatIs: string
|
||||
whatIsDesc: string
|
||||
selfHost: string
|
||||
selfHostLink: string
|
||||
close: string
|
||||
titleBefore: string;
|
||||
titleAfter: string;
|
||||
title: string;
|
||||
description: string;
|
||||
resetIn: string;
|
||||
minutes: string;
|
||||
uploadNote: string;
|
||||
fullVersionTitle: string;
|
||||
features: string[];
|
||||
addonsTitle: string;
|
||||
addons: [string, string][];
|
||||
whatIs: string;
|
||||
whatIsDesc: string;
|
||||
selfHost: string;
|
||||
selfHostLink: string;
|
||||
close: string;
|
||||
}
|
||||
|
||||
const texts: Record<string, DemoTexts> = {
|
||||
@@ -26,7 +42,8 @@ const texts: Record<string, DemoTexts> = {
|
||||
titleBefore: 'Willkommen bei ',
|
||||
titleAfter: '',
|
||||
title: 'Willkommen zur TREK Demo',
|
||||
description: 'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.',
|
||||
description:
|
||||
'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.',
|
||||
resetIn: 'Naechster Reset in',
|
||||
minutes: 'Minuten',
|
||||
uploadNote: 'Datei-Uploads (Fotos, Dokumente, Cover) sind in der Demo deaktiviert.',
|
||||
@@ -49,7 +66,8 @@ const texts: Record<string, DemoTexts> = {
|
||||
['Widgets', 'Waehrungsrechner & Zeitzonen'],
|
||||
],
|
||||
whatIs: 'Was ist TREK?',
|
||||
whatIsDesc: 'Ein selbst-gehosteter Reiseplaner mit Echtzeit-Kollaboration, interaktiver Karte, OIDC Login und Dark Mode.',
|
||||
whatIsDesc:
|
||||
'Ein selbst-gehosteter Reiseplaner mit Echtzeit-Kollaboration, interaktiver Karte, OIDC Login und Dark Mode.',
|
||||
selfHost: 'Open Source — ',
|
||||
selfHostLink: 'selbst hosten',
|
||||
close: 'Verstanden',
|
||||
@@ -81,7 +99,8 @@ const texts: Record<string, DemoTexts> = {
|
||||
['Widgets', 'Currency converter & timezones'],
|
||||
],
|
||||
whatIs: 'What is TREK?',
|
||||
whatIsDesc: 'A self-hosted travel planner with real-time collaboration, interactive maps, OIDC login and dark mode.',
|
||||
whatIsDesc:
|
||||
'A self-hosted travel planner with real-time collaboration, interactive maps, OIDC login and dark mode.',
|
||||
selfHost: 'Open source — ',
|
||||
selfHostLink: 'self-host it',
|
||||
close: 'Got it',
|
||||
@@ -113,7 +132,8 @@ const texts: Record<string, DemoTexts> = {
|
||||
['Widgets', 'Conversor de divisas y zonas horarias'],
|
||||
],
|
||||
whatIs: '¿Qué es TREK?',
|
||||
whatIsDesc: 'Un planificador de viajes autohospedado con colaboración en tiempo real, mapas interactivos, inicio de sesión OIDC y modo oscuro.',
|
||||
whatIsDesc:
|
||||
'Un planificador de viajes autohospedado con colaboración en tiempo real, mapas interactivos, inicio de sesión OIDC y modo oscuro.',
|
||||
selfHost: 'Código abierto — ',
|
||||
selfHostLink: 'alójalo tú mismo',
|
||||
close: 'Entendido',
|
||||
@@ -218,7 +238,8 @@ const texts: Record<string, DemoTexts> = {
|
||||
titleBefore: 'Selamat datang di ',
|
||||
titleAfter: '',
|
||||
title: 'Selamat datang di Demo TREK',
|
||||
description: 'Anda dapat melihat, mengedit, dan membuat perjalanan. Semua perubahan akan diatur ulang secara otomatis setiap jam.',
|
||||
description:
|
||||
'Anda dapat melihat, mengedit, dan membuat perjalanan. Semua perubahan akan diatur ulang secara otomatis setiap jam.',
|
||||
resetIn: 'Atur ulang berikutnya dalam',
|
||||
minutes: 'menit',
|
||||
uploadNote: 'Unggah file (foto, dokumen, sampul) dinonaktifkan dalam mode demo.',
|
||||
@@ -241,159 +262,309 @@ const texts: Record<string, DemoTexts> = {
|
||||
['Widget', 'Konverter mata uang & zona waktu'],
|
||||
],
|
||||
whatIs: 'Apa itu TREK?',
|
||||
whatIsDesc: 'Perencana perjalanan yang di-host sendiri dengan kolaborasi real-time, peta interaktif, login OIDC, dan mode gelap.',
|
||||
whatIsDesc:
|
||||
'Perencana perjalanan yang di-host sendiri dengan kolaborasi real-time, peta interaktif, login OIDC, dan mode gelap.',
|
||||
selfHost: 'Buka sumber — ',
|
||||
selfHostLink: 'host mandiri',
|
||||
close: 'Mengerti',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
|
||||
const addonIcons = [CalendarDays, Globe, ListChecks, Wallet, FileText, ArrowRightLeft]
|
||||
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield];
|
||||
const addonIcons = [CalendarDays, Globe, ListChecks, Wallet, FileText, ArrowRightLeft];
|
||||
|
||||
export default function DemoBanner(): React.ReactElement | null {
|
||||
const [dismissed, setDismissed] = useState<boolean>(false)
|
||||
const [minutesLeft, setMinutesLeft] = useState<number>(59 - new Date().getMinutes())
|
||||
const { language } = useTranslation()
|
||||
const t = texts[language] || texts.en
|
||||
const [dismissed, setDismissed] = useState<boolean>(false);
|
||||
const [minutesLeft, setMinutesLeft] = useState<number>(59 - new Date().getMinutes());
|
||||
const { language } = useTranslation();
|
||||
const t = texts[language] || texts.en;
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setMinutesLeft(59 - new Date().getMinutes()), 10000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
const interval = setInterval(() => setMinutesLeft(59 - new Date().getMinutes()), 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (dismissed) return null
|
||||
if (dismissed) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0, zIndex: 99999,
|
||||
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
paddingTop: 'max(16px, env(safe-area-inset-top))',
|
||||
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
|
||||
paddingLeft: 16, paddingRight: 16,
|
||||
overflow: 'auto',
|
||||
fontFamily: "var(--font-system)",
|
||||
}} onClick={() => setDismissed(true)}>
|
||||
<div style={{
|
||||
background: 'white', borderRadius: 20, padding: '28px 24px 0',
|
||||
maxWidth: 480, width: '100%',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
maxHeight: 'min(90vh, calc(100dvh - 96px))',
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 99999,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingTop: 'max(16px, env(safe-area-inset-top))',
|
||||
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
overflow: 'auto',
|
||||
display: 'flex', flexDirection: 'column',
|
||||
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
||||
|
||||
fontFamily: 'var(--font-system)',
|
||||
}}
|
||||
onClick={() => setDismissed(true)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'white',
|
||||
borderRadius: 20,
|
||||
padding: '28px 24px 0',
|
||||
maxWidth: 480,
|
||||
width: '100%',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
maxHeight: 'min(90vh, calc(100dvh - 96px))',
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||
<img src="/icons/icon-dark.svg" alt="" style={{ width: 36, height: 36, borderRadius: 10 }} />
|
||||
<h2 style={{ margin: 0, fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))', fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
{t.titleBefore}<img src="/text-dark.svg" alt="TREK" style={{ height: 18 }} />{t.titleAfter}
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))',
|
||||
fontWeight: 700,
|
||||
color: '#111827',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
}}
|
||||
>
|
||||
{t.titleBefore}
|
||||
<img src="/text-dark.svg" alt="TREK" style={{ height: 18 }} />
|
||||
{t.titleAfter}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', color: '#6b7280', lineHeight: 1.6, margin: '0 0 12px' }}>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
color: '#6b7280',
|
||||
lineHeight: 1.6,
|
||||
margin: '0 0 12px',
|
||||
}}
|
||||
>
|
||||
{t.description}
|
||||
</p>
|
||||
|
||||
{/* Timer + Upload note */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 10, padding: '8px 10px',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
background: '#f0f9ff',
|
||||
border: '1px solid #bae6fd',
|
||||
borderRadius: 10,
|
||||
padding: '8px 10px',
|
||||
}}
|
||||
>
|
||||
<Clock size={13} style={{ flexShrink: 0, color: '#0284c7' }} />
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#0369a1', fontWeight: 600 }}>
|
||||
{t.resetIn} {minutesLeft} {t.minutes}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, padding: '8px 10px',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
background: '#fffbeb',
|
||||
border: '1px solid #fde68a',
|
||||
borderRadius: 10,
|
||||
padding: '8px 10px',
|
||||
}}
|
||||
>
|
||||
<Upload size={13} style={{ flexShrink: 0, color: '#b45309' }} />
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#b45309' }}>{t.uploadNote}</span>
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#b45309' }}>
|
||||
{t.uploadNote}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What is TREK */}
|
||||
<div style={{
|
||||
background: '#f8fafc', borderRadius: 12, padding: '12px 14px', marginBottom: 16,
|
||||
border: '1px solid #e2e8f0',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
background: '#f8fafc',
|
||||
borderRadius: 12,
|
||||
padding: '12px 14px',
|
||||
marginBottom: 16,
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<Map size={14} style={{ color: '#111827' }} />
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 700,
|
||||
color: '#111827',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{t.whatIs}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p>
|
||||
<p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: '#64748b', lineHeight: 1.5, margin: 0 }}>
|
||||
{t.whatIsDesc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Addons */}
|
||||
<p style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 700,
|
||||
color: '#374151',
|
||||
margin: '0 0 8px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<Puzzle size={12} />
|
||||
{t.addonsTitle}
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
|
||||
{t.addons.map(([name, desc], i) => {
|
||||
const Icon = addonIcons[i]
|
||||
const Icon = addonIcons[i];
|
||||
return (
|
||||
<div key={name} style={{
|
||||
background: '#f8fafc', borderRadius: 10, padding: '8px 10px',
|
||||
border: '1px solid #f1f5f9',
|
||||
}}>
|
||||
<div
|
||||
key={name}
|
||||
style={{
|
||||
background: '#f8fafc',
|
||||
borderRadius: 10,
|
||||
padding: '8px 10px',
|
||||
border: '1px solid #f1f5f9',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
||||
<Icon size={12} style={{ flexShrink: 0, color: '#111827' }} />
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: '#111827' }}>{name}</span>
|
||||
<span
|
||||
style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: '#111827' }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: '#94a3b8', margin: 0, lineHeight: 1.3, paddingLeft: 18 }}>{desc}</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
color: '#94a3b8',
|
||||
margin: 0,
|
||||
lineHeight: 1.3,
|
||||
paddingLeft: 18,
|
||||
}}
|
||||
>
|
||||
{desc}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Full version features */}
|
||||
<p style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 700,
|
||||
color: '#374151',
|
||||
margin: '0 0 8px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<Shield size={12} />
|
||||
{t.fullVersionTitle}
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
|
||||
{t.features.map((text, i) => {
|
||||
const Icon = featureIcons[i]
|
||||
const Icon = featureIcons[i];
|
||||
return (
|
||||
<div key={text} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#4b5563', padding: '4px 0' }}>
|
||||
<div
|
||||
key={text}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
color: '#4b5563',
|
||||
padding: '4px 0',
|
||||
}}
|
||||
>
|
||||
<Icon size={13} style={{ flexShrink: 0, color: '#9ca3af' }} />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: '14px 0 20px', borderTop: '1px solid #e5e7eb',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
position: 'sticky', bottom: 0, background: 'white',
|
||||
marginTop: 'auto',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#9ca3af' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '14px 0 20px',
|
||||
borderTop: '1px solid #e5e7eb',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
background: 'white',
|
||||
marginTop: 'auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
color: '#9ca3af',
|
||||
}}
|
||||
>
|
||||
<Github size={13} />
|
||||
<span>{t.selfHost}</span>
|
||||
<a href="https://github.com/mauriceboe/TREK" target="_blank" rel="noopener noreferrer"
|
||||
style={{ color: '#111827', fontWeight: 600, textDecoration: 'none' }}>
|
||||
<a
|
||||
href="https://github.com/liketrek/TREK"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#111827', fontWeight: 600, textDecoration: 'none' }}
|
||||
>
|
||||
{t.selfHostLink}
|
||||
</a>
|
||||
</div>
|
||||
<button onClick={() => setDismissed(true)} style={{
|
||||
background: '#111827', color: 'white', border: 'none',
|
||||
borderRadius: 10, padding: '8px 20px', fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
style={{
|
||||
background: '#111827',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 10,
|
||||
padding: '8px 20px',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t.close}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores } from '../../../tests/helpers/store';
|
||||
import AboutTab from './AboutTab';
|
||||
|
||||
@@ -41,10 +41,7 @@ describe('AboutTab', () => {
|
||||
render(<AboutTab appVersion="2.9.10" />);
|
||||
const link = document.querySelector('a[href*="issues/new"]');
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute(
|
||||
'href',
|
||||
'https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml',
|
||||
);
|
||||
expect(link).toHaveAttribute('href', 'https://github.com/liketrek/TREK/issues/new?template=bug_report.yml');
|
||||
});
|
||||
|
||||
it('FE-COMP-ABOUT-007: displays feature request link', () => {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React from 'react'
|
||||
import { Info, Coffee, Heart, ExternalLink, Bug, Lightbulb, BookOpen } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import Section from './Section'
|
||||
import { BookOpen, Bug, Coffee, ExternalLink, Heart, Info, Lightbulb } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import Section from './Section';
|
||||
|
||||
interface Props {
|
||||
appVersion: string
|
||||
appVersion: string;
|
||||
}
|
||||
|
||||
export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
const { t, locale } = useTranslation()
|
||||
const { t, locale } = useTranslation();
|
||||
|
||||
return (
|
||||
<Section title={t('settings.about')} icon={Info}>
|
||||
@@ -18,26 +18,67 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
50% { transform: scale(1.15); }
|
||||
}
|
||||
`}</style>
|
||||
<p className="text-content-secondary" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, marginBottom: 6, marginTop: -4 }}>
|
||||
<p
|
||||
className="text-content-secondary"
|
||||
style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, marginBottom: 6, marginTop: -4 }}
|
||||
>
|
||||
{t('settings.about.description')}
|
||||
</p>
|
||||
<p className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.6, marginBottom: 16 }}>
|
||||
<p
|
||||
className="text-content-faint"
|
||||
style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.6, marginBottom: 16 }}
|
||||
>
|
||||
{t('settings.about.madeWith')}{' '}
|
||||
<Heart size={11} fill="#991b1b" stroke="#991b1b" style={{ display: 'inline-block', verticalAlign: '-1px', animation: 'heartPulse 1.5s ease-in-out infinite' }} />
|
||||
{' '}{t('settings.about.madeBy')}{' '}
|
||||
<span className="text-content-faint bg-surface-tertiary" style={{ display: 'inline-flex', alignItems: 'center', borderRadius: 99, padding: '1px 7px', fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, verticalAlign: '1px' }}>v{appVersion}</span>
|
||||
<Heart
|
||||
size={11}
|
||||
fill="#991b1b"
|
||||
stroke="#991b1b"
|
||||
style={{ display: 'inline-block', verticalAlign: '-1px', animation: 'heartPulse 1.5s ease-in-out infinite' }}
|
||||
/>{' '}
|
||||
{t('settings.about.madeBy')}{' '}
|
||||
<span
|
||||
className="bg-surface-tertiary text-content-faint"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: 99,
|
||||
padding: '1px 7px',
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 600,
|
||||
verticalAlign: '1px',
|
||||
}}
|
||||
>
|
||||
v{appVersion}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<a
|
||||
href="https://ko-fi.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ff5e5b';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#ff5e5b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<div
|
||||
className="bg-[#ff5e5b15]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Coffee size={20} className="text-[#ff5e5b]" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -50,11 +91,28 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
href="https://buymeacoffee.com/mauriceboe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ffdd00';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#ffdd0015]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<div
|
||||
className="bg-[#ffdd0015]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Heart size={20} className="text-[#ffdd00]" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -67,12 +125,31 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
href="https://discord.gg/NhZBDSd4qW"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#5865F2'; e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#5865F2';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #5865F222';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#5865F215]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
|
||||
<div
|
||||
className="bg-[#5865F215]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="#5865F2">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-content">Discord</div>
|
||||
@@ -82,16 +159,33 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-3">
|
||||
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/issues/new?template=bug_report.yml"
|
||||
href="https://github.com/liketrek/TREK/issues/new?template=bug_report.yml"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ef4444'; e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#ef4444';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #ef444422';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#ef444415]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<div
|
||||
className="bg-[#ef444415]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Bug size={20} className="text-[#ef4444]" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -101,14 +195,31 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/discussions/new?category=feature-requests"
|
||||
href="https://github.com/liketrek/TREK/discussions/new?category=feature-requests"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#f59e0b'; e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#f59e0b';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #f59e0b22';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#f59e0b15]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<div
|
||||
className="bg-[#f59e0b15]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Lightbulb size={20} className="text-[#f59e0b]" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -118,14 +229,31 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/wiki"
|
||||
href="https://github.com/liketrek/TREK/wiki"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] bg-surface-card border-edge no-underline"
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
className="flex items-center gap-4 overflow-hidden rounded-xl border border-edge bg-surface-card px-5 py-4 no-underline transition-[border-color,box-shadow] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]"
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = '#6366f1';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 1px #6366f122';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--border-primary)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<div className="bg-[#6366f115]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<div
|
||||
className="bg-[#6366f115]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<BookOpen size={20} className="text-[#6366f1]" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -135,7 +263,6 @@ export default function AboutTab({ appVersion }: Props): React.ReactElement {
|
||||
<ExternalLink size={14} className="ml-auto flex-shrink-0 text-content-faint" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</Section>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,159 +1,241 @@
|
||||
import { useTranslation, SUPPORTED_LANGUAGES } from '../i18n'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import {
|
||||
List, Grid, MapPin, Camera, BookOpen, Image, Clock, Play,
|
||||
Laugh, Smile, Meh, Frown,
|
||||
Sun, CloudSun, Cloud, CloudRain, CloudLightning, Snowflake,
|
||||
ThumbsUp, ThumbsDown,
|
||||
} from 'lucide-react'
|
||||
import JourneyMap from '../components/Journey/JourneyMap'
|
||||
import JournalBody from '../components/Journey/JournalBody'
|
||||
import PhotoLightbox from '../components/Journey/PhotoLightbox'
|
||||
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
|
||||
import MobileEntryView from '../components/Journey/MobileEntryView'
|
||||
import { formatLocationName } from '../utils/formatters'
|
||||
import { DAY_COLORS } from '../components/Journey/dayColors'
|
||||
import { useJourneyPublic } from './journeyPublic/useJourneyPublic'
|
||||
BookOpen,
|
||||
Camera,
|
||||
Clock,
|
||||
Cloud,
|
||||
CloudLightning,
|
||||
CloudRain,
|
||||
CloudSun,
|
||||
Frown,
|
||||
Grid,
|
||||
Image,
|
||||
Laugh,
|
||||
List,
|
||||
MapPin,
|
||||
Meh,
|
||||
Play,
|
||||
Smile,
|
||||
Snowflake,
|
||||
Sun,
|
||||
ThumbsDown,
|
||||
ThumbsUp,
|
||||
} from 'lucide-react';
|
||||
import { DAY_COLORS } from '../components/Journey/dayColors';
|
||||
import JournalBody from '../components/Journey/JournalBody';
|
||||
import JourneyMap from '../components/Journey/JourneyMap';
|
||||
import MobileEntryView from '../components/Journey/MobileEntryView';
|
||||
import MobileMapTimeline from '../components/Journey/MobileMapTimeline';
|
||||
import PhotoLightbox from '../components/Journey/PhotoLightbox';
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
import { formatLocationName } from '../utils/formatters';
|
||||
import { useJourneyPublic } from './journeyPublic/useJourneyPublic';
|
||||
|
||||
const MOOD_CONFIG: Record<string, { icon: typeof Smile; label: string; bg: string; text: string }> = {
|
||||
amazing: { icon: Laugh, label: 'Amazing', bg: 'bg-pink-50 dark:bg-pink-900/20', text: 'text-pink-600 dark:text-pink-400' },
|
||||
good: { icon: Smile, label: 'Good', bg: 'bg-amber-50 dark:bg-amber-900/20', text: 'text-amber-600 dark:text-amber-400' },
|
||||
neutral: { icon: Meh, label: 'Neutral', bg: 'bg-zinc-100 dark:bg-zinc-800', text: 'text-zinc-500 dark:text-zinc-400' },
|
||||
rough: { icon: Frown, label: 'Rough', bg: 'bg-violet-50 dark:bg-violet-900/20', text: 'text-violet-600 dark:text-violet-400' },
|
||||
}
|
||||
amazing: {
|
||||
icon: Laugh,
|
||||
label: 'Amazing',
|
||||
bg: 'bg-pink-50 dark:bg-pink-900/20',
|
||||
text: 'text-pink-600 dark:text-pink-400',
|
||||
},
|
||||
good: {
|
||||
icon: Smile,
|
||||
label: 'Good',
|
||||
bg: 'bg-amber-50 dark:bg-amber-900/20',
|
||||
text: 'text-amber-600 dark:text-amber-400',
|
||||
},
|
||||
neutral: {
|
||||
icon: Meh,
|
||||
label: 'Neutral',
|
||||
bg: 'bg-zinc-100 dark:bg-zinc-800',
|
||||
text: 'text-zinc-500 dark:text-zinc-400',
|
||||
},
|
||||
rough: {
|
||||
icon: Frown,
|
||||
label: 'Rough',
|
||||
bg: 'bg-violet-50 dark:bg-violet-900/20',
|
||||
text: 'text-violet-600 dark:text-violet-400',
|
||||
},
|
||||
};
|
||||
|
||||
const WEATHER_CONFIG: Record<string, { icon: typeof Sun; label: string }> = {
|
||||
sunny: { icon: Sun, label: 'Sunny' },
|
||||
partly: { icon: CloudSun, label: 'Partly cloudy' },
|
||||
cloudy: { icon: Cloud, label: 'Cloudy' },
|
||||
rainy: { icon: CloudRain, label: 'Rainy' },
|
||||
stormy: { icon: CloudLightning, label: 'Stormy' },
|
||||
cold: { icon: Snowflake, label: 'Cold' },
|
||||
}
|
||||
sunny: { icon: Sun, label: 'Sunny' },
|
||||
partly: { icon: CloudSun, label: 'Partly cloudy' },
|
||||
cloudy: { icon: Cloud, label: 'Cloudy' },
|
||||
rainy: { icon: CloudRain, label: 'Rainy' },
|
||||
stormy: { icon: CloudLightning, label: 'Stormy' },
|
||||
cold: { icon: Snowflake, label: 'Cold' },
|
||||
};
|
||||
|
||||
function photoUrl(p: { photo_id: number }, shareToken: string, kind: 'thumbnail' | 'original' = 'original'): string {
|
||||
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`
|
||||
return `/api/public/journey/${shareToken}/photos/${p.photo_id}/${kind}`;
|
||||
}
|
||||
|
||||
function formatDate(d: string, locale?: string): { weekday: string; month: string; day: number } {
|
||||
const date = new Date(d + 'T00:00:00')
|
||||
const date = new Date(d + 'T00:00:00');
|
||||
return {
|
||||
weekday: date.toLocaleDateString(locale || 'en', { weekday: 'long' }),
|
||||
month: date.toLocaleDateString(locale || 'en', { month: 'long' }),
|
||||
day: date.getDate(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default function JourneyPublicPage() {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
// Page = wiring container: the share fetch, view state and all timeline/map
|
||||
// derivations live in the hook; the render helpers below stay next to the JSX.
|
||||
const {
|
||||
token, data, loading, error, isMobile, locale,
|
||||
view, setView, lightbox, setLightbox, showLangPicker, setShowLangPicker,
|
||||
mapRef, activeEntryId, setActiveEntryId, viewingEntry, setViewingEntry, handleMarkerClick,
|
||||
perms, journey, stats,
|
||||
timelineEntries, groupedEntries, sortedDates, sidebarMapItems, allPhotos,
|
||||
token,
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
isMobile,
|
||||
locale,
|
||||
view,
|
||||
setView,
|
||||
lightbox,
|
||||
setLightbox,
|
||||
showLangPicker,
|
||||
setShowLangPicker,
|
||||
mapRef,
|
||||
activeEntryId,
|
||||
setActiveEntryId,
|
||||
viewingEntry,
|
||||
setViewingEntry,
|
||||
handleMarkerClick,
|
||||
perms,
|
||||
journey,
|
||||
stats,
|
||||
timelineEntries,
|
||||
groupedEntries,
|
||||
sortedDates,
|
||||
sidebarMapItems,
|
||||
allPhotos,
|
||||
desktopTwoColumn,
|
||||
} = useJourneyPublic()
|
||||
} = useJourneyPublic();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-zinc-950">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-zinc-300 border-t-zinc-900" />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 flex items-center justify-center">
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 dark:bg-zinc-950">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white mb-2">{t('journey.public.notFound')}</h1>
|
||||
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-white">{t('journey.public.notFound')}</h1>
|
||||
<p className="text-zinc-500">{t('journey.public.notFoundMessage')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// In desktop two-column mode the map is always visible — exclude the standalone 'map' tab
|
||||
const availableViews = [
|
||||
perms.share_timeline && { id: 'timeline' as const, icon: List, label: t('journey.share.timeline') },
|
||||
perms.share_gallery && { id: 'gallery' as const, icon: Grid, label: t('journey.share.gallery') },
|
||||
!desktopTwoColumn && !isMobile && perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
||||
].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[]
|
||||
!desktopTwoColumn &&
|
||||
!isMobile &&
|
||||
perms.share_map && { id: 'map' as const, icon: MapPin, label: t('journey.share.map') },
|
||||
].filter(Boolean) as { id: 'timeline' | 'gallery' | 'map'; icon: any; label: string }[];
|
||||
|
||||
// Shared timeline renderer used in both layout modes
|
||||
const renderTimeline = () => (
|
||||
<div className="flex flex-col gap-6">
|
||||
{sortedDates.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center mx-auto mb-4">
|
||||
<div className="py-16 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800">
|
||||
<BookOpen size={24} className="text-zinc-400" />
|
||||
</div>
|
||||
<p className="text-[15px] font-medium text-zinc-700 dark:text-zinc-300">No entries yet</p>
|
||||
</div>
|
||||
)}
|
||||
{sortedDates.map((date, dayIdx) => {
|
||||
const dayEntries = groupedEntries.get(date)!
|
||||
const fd = formatDate(date, locale)
|
||||
const dayColor = DAY_COLORS[dayIdx % DAY_COLORS.length]
|
||||
const dayEntries = groupedEntries.get(date)!;
|
||||
const fd = formatDate(date, locale);
|
||||
const dayColor = DAY_COLORS[dayIdx % DAY_COLORS.length];
|
||||
return (
|
||||
<div key={date}>
|
||||
{/* Day header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center text-[14px] font-bold text-white flex-shrink-0"
|
||||
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl text-[14px] font-bold text-white"
|
||||
style={{ background: dayColor }}
|
||||
>
|
||||
{dayIdx + 1}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[14px] font-semibold text-zinc-900 dark:text-white">{fd.weekday}</div>
|
||||
<div className="text-[11px] text-zinc-500">{fd.month} {fd.day}</div>
|
||||
<div className="text-[11px] text-zinc-500">
|
||||
{fd.month} {fd.day}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Entries */}
|
||||
<div className="flex flex-col gap-4 pl-[52px]">
|
||||
{dayEntries.map(entry => {
|
||||
const photos = entry.photos || []
|
||||
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null
|
||||
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null
|
||||
const prosArr = entry.pros_cons?.pros ?? []
|
||||
const consArr = entry.pros_cons?.cons ?? []
|
||||
const hasProscons = prosArr.length > 0 || consArr.length > 0
|
||||
const lightboxPhotos = photos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption, mediaType: (p as any).media_type }))
|
||||
{dayEntries.map((entry) => {
|
||||
const photos = entry.photos || [];
|
||||
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : null;
|
||||
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : null;
|
||||
const prosArr = entry.pros_cons?.pros ?? [];
|
||||
const consArr = entry.pros_cons?.cons ?? [];
|
||||
const hasProscons = prosArr.length > 0 || consArr.length > 0;
|
||||
const lightboxPhotos = photos.map((p) => ({
|
||||
id: String(p.id),
|
||||
src: photoUrl(p, token!, 'original'),
|
||||
caption: p.caption,
|
||||
mediaType: (p as any).media_type,
|
||||
}));
|
||||
|
||||
const isActive = activeEntryId === String(entry.id)
|
||||
const isActive = activeEntryId === String(entry.id);
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
data-entry-id={String(entry.id)}
|
||||
onMouseEnter={() => {
|
||||
if (!desktopTwoColumn) return
|
||||
setActiveEntryId(String(entry.id))
|
||||
mapRef.current?.highlightMarker(String(entry.id))
|
||||
if (!desktopTwoColumn) return;
|
||||
setActiveEntryId(String(entry.id));
|
||||
mapRef.current?.highlightMarker(String(entry.id));
|
||||
}}
|
||||
style={isActive && desktopTwoColumn ? { outline: `2px solid ${dayColor}`, outlineOffset: '3px', borderRadius: '16px' } : undefined}
|
||||
className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-2xl overflow-hidden">
|
||||
|
||||
style={
|
||||
isActive && desktopTwoColumn
|
||||
? { outline: `2px solid ${dayColor}`, outlineOffset: '3px', borderRadius: '16px' }
|
||||
: undefined
|
||||
}
|
||||
className="overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
|
||||
>
|
||||
{/* Photo area */}
|
||||
{photos.length === 1 && (
|
||||
<div className="relative cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}>
|
||||
<img src={photoUrl(photos[0], token!)} className="w-full h-64 object-cover" alt="" />
|
||||
<div className="absolute inset-x-0 bottom-0 pointer-events-none" style={{ background: 'linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.15) 60%, transparent 100%)', height: '65%' }} />
|
||||
<div
|
||||
className="relative cursor-pointer"
|
||||
onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}
|
||||
>
|
||||
<img src={photoUrl(photos[0], token!)} className="h-64 w-full object-cover" alt="" />
|
||||
<div
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to top, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0.15) 60%, transparent 100%)',
|
||||
height: '65%',
|
||||
}}
|
||||
/>
|
||||
{entry.location_name && (
|
||||
<div className="absolute top-3 left-4">
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-black/40 backdrop-blur-sm rounded-full text-[10px] font-semibold text-white">
|
||||
<div className="absolute left-4 top-3">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-black/40 px-2.5 py-1 text-[10px] font-semibold text-white backdrop-blur-sm">
|
||||
<MapPin size={10} className="flex-shrink-0" />
|
||||
<span className="truncate max-w-[200px]">{formatLocationName(entry.location_name)}</span>
|
||||
<span className="max-w-[200px] truncate">{formatLocationName(entry.location_name)}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{entry.title && (
|
||||
<div className="absolute bottom-4 left-5 right-5 pointer-events-none">
|
||||
<h3 className="text-[18px] font-bold text-white drop-shadow-sm leading-tight">{entry.title}</h3>
|
||||
<div className="pointer-events-none absolute bottom-4 left-5 right-5">
|
||||
<h3 className="text-[18px] font-bold leading-tight text-white drop-shadow-sm">
|
||||
{entry.title}
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -166,7 +248,7 @@ export default function JourneyPublicPage() {
|
||||
key={p.id}
|
||||
src={photoUrl(p, token!, 'thumbnail')}
|
||||
alt=""
|
||||
className="w-full h-52 object-cover cursor-pointer"
|
||||
className="h-52 w-full cursor-pointer object-cover"
|
||||
onClick={() => setLightbox({ photos: lightboxPhotos, index: i })}
|
||||
/>
|
||||
))}
|
||||
@@ -174,19 +256,40 @@ export default function JourneyPublicPage() {
|
||||
)}
|
||||
|
||||
{photos.length >= 3 && (
|
||||
<div className="overflow-hidden flex" style={{ height: 280, gap: 2 }}>
|
||||
<div className="flex-1 min-w-0 cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}>
|
||||
<img src={photoUrl(photos[0], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
|
||||
<div className="flex overflow-hidden" style={{ height: 280, gap: 2 }}>
|
||||
<div
|
||||
className="min-w-0 flex-1 cursor-pointer"
|
||||
onClick={() => setLightbox({ photos: lightboxPhotos, index: 0 })}
|
||||
>
|
||||
<img
|
||||
src={photoUrl(photos[0], token!, 'thumbnail')}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col" style={{ gap: 2 }}>
|
||||
<div className="flex-1 min-h-0 cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 1 })}>
|
||||
<img src={photoUrl(photos[1], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
|
||||
<div className="flex min-w-0 flex-1 flex-col" style={{ gap: 2 }}>
|
||||
<div
|
||||
className="min-h-0 flex-1 cursor-pointer"
|
||||
onClick={() => setLightbox({ photos: lightboxPhotos, index: 1 })}
|
||||
>
|
||||
<img
|
||||
src={photoUrl(photos[1], token!, 'thumbnail')}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 relative cursor-pointer" onClick={() => setLightbox({ photos: lightboxPhotos, index: 2 })}>
|
||||
<img src={photoUrl(photos[2], token!, 'thumbnail')} alt="" className="w-full h-full object-cover" />
|
||||
<div
|
||||
className="relative min-h-0 flex-1 cursor-pointer"
|
||||
onClick={() => setLightbox({ photos: lightboxPhotos, index: 2 })}
|
||||
>
|
||||
<img
|
||||
src={photoUrl(photos[2], token!, 'thumbnail')}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
{photos.length > 3 && (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
|
||||
<span className="text-white text-[13px] font-semibold flex items-center gap-1">
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
|
||||
<span className="flex items-center gap-1 text-[13px] font-semibold text-white">
|
||||
<Image size={13} /> +{photos.length - 3}
|
||||
</span>
|
||||
</div>
|
||||
@@ -197,15 +300,17 @@ export default function JourneyPublicPage() {
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-5 pt-4 pb-5 cursor-pointer" onClick={() => setViewingEntry(entry)}>
|
||||
<div className="cursor-pointer px-5 pb-5 pt-4" onClick={() => setViewingEntry(entry)}>
|
||||
{/* Title (only when no single photo — photo has it in overlay) */}
|
||||
{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="mb-2 text-[16px] font-semibold leading-snug tracking-tight text-zinc-900 dark:text-white">
|
||||
{entry.title}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{/* Location + time badges */}
|
||||
{(entry.location_name || entry.entry_time) && photos.length !== 1 && (
|
||||
<div className="flex items-center gap-2 flex-wrap mb-2">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
{entry.location_name && (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-zinc-500">
|
||||
<MapPin size={11} className="flex-shrink-0" />
|
||||
@@ -221,7 +326,7 @@ export default function JourneyPublicPage() {
|
||||
</div>
|
||||
)}
|
||||
{entry.entry_time && photos.length === 1 && (
|
||||
<div className="flex items-center gap-1 text-[11px] text-zinc-400 mb-2">
|
||||
<div className="mb-2 flex items-center gap-1 text-[11px] text-zinc-400">
|
||||
<Clock size={11} />
|
||||
{entry.entry_time.slice(0, 5)}
|
||||
</div>
|
||||
@@ -229,34 +334,44 @@ export default function JourneyPublicPage() {
|
||||
|
||||
{/* Story */}
|
||||
{entry.story && (
|
||||
<div className="text-[13px] text-zinc-700 dark:text-zinc-300 leading-relaxed">
|
||||
<div className="text-[13px] leading-relaxed text-zinc-700 dark:text-zinc-300">
|
||||
<JournalBody text={entry.story} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pros & Cons */}
|
||||
{hasProscons && (
|
||||
<div className={`grid gap-3 mt-4 ${prosArr.length > 0 && consArr.length > 0 ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
<div
|
||||
className={`mt-4 grid gap-3 ${prosArr.length > 0 && consArr.length > 0 ? 'grid-cols-2' : 'grid-cols-1'}`}
|
||||
>
|
||||
{prosArr.length > 0 && (
|
||||
<div className="rounded-xl border border-green-200 dark:border-green-800/30 p-3" style={{ background: 'linear-gradient(180deg, #F0FDF4 0%, white 100%)' }}>
|
||||
<div className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-green-700 mb-2">
|
||||
<div
|
||||
className="rounded-xl border border-green-200 p-3 dark:border-green-800/30"
|
||||
style={{ background: 'linear-gradient(180deg, #F0FDF4 0%, white 100%)' }}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-green-700">
|
||||
<ThumbsUp size={10} /> Pros
|
||||
</div>
|
||||
{prosArr.map((p, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5 text-[12px] text-green-900 mb-1">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-green-500 flex-shrink-0 mt-[6px]" />{p}
|
||||
<div key={i} className="mb-1 flex items-start gap-1.5 text-[12px] text-green-900">
|
||||
<span className="mt-[6px] h-[5px] w-[5px] flex-shrink-0 rounded-full bg-green-500" />
|
||||
{p}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{consArr.length > 0 && (
|
||||
<div className="rounded-xl border border-red-200 dark:border-red-800/30 p-3" style={{ background: 'linear-gradient(180deg, #FEF2F2 0%, white 100%)' }}>
|
||||
<div className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-red-700 mb-2">
|
||||
<div
|
||||
className="rounded-xl border border-red-200 p-3 dark:border-red-800/30"
|
||||
style={{ background: 'linear-gradient(180deg, #FEF2F2 0%, white 100%)' }}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wide text-red-700">
|
||||
<ThumbsDown size={10} /> Cons
|
||||
</div>
|
||||
{consArr.map((c, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5 text-[12px] text-red-900 mb-1">
|
||||
<span className="w-[5px] h-[5px] rounded-full bg-red-500 flex-shrink-0 mt-[6px]" />{c}
|
||||
<div key={i} className="mb-1 flex items-start gap-1.5 text-[12px] text-red-900">
|
||||
<span className="mt-[6px] h-[5px] w-[5px] flex-shrink-0 rounded-full bg-red-500" />
|
||||
{c}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -266,14 +381,16 @@ export default function JourneyPublicPage() {
|
||||
|
||||
{/* Mood + weather */}
|
||||
{(mood || weather) && (
|
||||
<div className="flex items-center gap-1.5 pt-3 mt-3 border-t border-zinc-100 dark:border-zinc-800">
|
||||
<div className="mt-3 flex items-center gap-1.5 border-t border-zinc-100 pt-3 dark:border-zinc-800">
|
||||
{mood && (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium ${mood.bg} ${mood.text}`}>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium ${mood.bg} ${mood.text}`}
|
||||
>
|
||||
<mood.icon size={11} /> {mood.label}
|
||||
</span>
|
||||
)}
|
||||
{weather && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-zinc-100 px-2 py-0.5 text-[10px] font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400">
|
||||
<weather.icon size={11} /> {weather.label}
|
||||
</span>
|
||||
)}
|
||||
@@ -281,28 +398,43 @@ export default function JourneyPublicPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
// Shared gallery renderer
|
||||
const renderGallery = () => (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-1.5">
|
||||
<div className="grid grid-cols-2 gap-1.5 sm:grid-cols-3 md:grid-cols-4">
|
||||
{allPhotos.map((photo, idx) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="relative aspect-square rounded-lg overflow-hidden cursor-pointer"
|
||||
onClick={() => setLightbox({ photos: allPhotos.map(p => ({ id: String(p.id), src: photoUrl(p, token!, 'original'), caption: p.caption, mediaType: (p as any).media_type })), index: idx })}
|
||||
className="relative aspect-square cursor-pointer overflow-hidden rounded-lg"
|
||||
onClick={() =>
|
||||
setLightbox({
|
||||
photos: allPhotos.map((p) => ({
|
||||
id: String(p.id),
|
||||
src: photoUrl(p, token!, 'original'),
|
||||
caption: p.caption,
|
||||
mediaType: (p as any).media_type,
|
||||
})),
|
||||
index: idx,
|
||||
})
|
||||
}
|
||||
>
|
||||
<img src={photoUrl(photo, token!, 'thumbnail')} className="w-full h-full object-cover hover:scale-105 transition-transform" alt="" loading="lazy" />
|
||||
<img
|
||||
src={photoUrl(photo, token!, 'thumbnail')}
|
||||
className="h-full w-full object-cover transition-transform hover:scale-105"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
{(photo as any).media_type === 'video' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<span className="w-9 h-9 rounded-full bg-black/55 backdrop-blur flex items-center justify-center text-white">
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur">
|
||||
<Play size={16} className="ml-0.5" fill="currentColor" />
|
||||
</span>
|
||||
</div>
|
||||
@@ -310,94 +442,275 @@ export default function JourneyPublicPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
// Shared view tab bar
|
||||
const renderTabs = (views: typeof availableViews) => views.length > 1 && (
|
||||
<div className="flex bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden mb-6 w-fit">
|
||||
{views.map(v => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setView(v.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
|
||||
view === v.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<v.icon size={13} />
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
const renderTabs = (views: typeof availableViews) =>
|
||||
views.length > 1 && (
|
||||
<div className="mb-6 flex w-fit overflow-hidden rounded-lg border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-800">
|
||||
{views.map((v) => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setView(v.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
|
||||
view === v.id
|
||||
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
|
||||
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<v.icon size={13} />
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950">
|
||||
{/* 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',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div style={{ position: 'absolute', top: -60, right: -60, width: 200, height: 200, borderRadius: '50%', background: 'rgba(255,255,255,0.03)' }} />
|
||||
<div style={{ position: 'absolute', bottom: -40, left: -40, width: 150, height: 150, borderRadius: '50%', background: 'rgba(255,255,255,0.02)' }} />
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -60,
|
||||
right: -60,
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: -40,
|
||||
left: -40,
|
||||
width: 150,
|
||||
height: 150,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Language picker */}
|
||||
<div style={{ position: 'absolute', top: 12, right: 12, zIndex: 10 }}>
|
||||
<button onClick={() => setShowLangPicker(v => !v)} style={{
|
||||
padding: '5px 12px', borderRadius: 20, border: '1px solid rgba(255,255,255,0.15)',
|
||||
background: 'rgba(255,255,255,0.1)', backdropFilter: 'blur(8px)',
|
||||
color: 'rgba(255,255,255,0.7)', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
{SUPPORTED_LANGUAGES.find(l => l.value === (locale?.split('-')[0] || 'en'))?.label || 'Language'}
|
||||
<button
|
||||
onClick={() => setShowLangPicker((v) => !v)}
|
||||
style={{
|
||||
padding: '5px 12px',
|
||||
borderRadius: 20,
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.find((l) => l.value === (locale?.split('-')[0] || 'en'))?.label || 'Language'}
|
||||
</button>
|
||||
{showLangPicker && (
|
||||
<div style={{ position: 'absolute', top: '100%', right: 0, marginTop: 6, background: 'white', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.2)', padding: 4, zIndex: 50, minWidth: 150 }}>
|
||||
{SUPPORTED_LANGUAGES.map(lang => (
|
||||
<button key={lang.value} onClick={() => {
|
||||
useSettingsStore.setState(s => ({ settings: { ...s.settings, language: lang.value } }))
|
||||
setShowLangPicker(false)
|
||||
}}
|
||||
style={{ display: 'block', width: '100%', padding: '6px 12px', border: 'none', background: 'none', textAlign: 'left', cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: '#374151', borderRadius: 6, fontFamily: 'inherit' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#f3f4f6'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
>{lang.label}</button>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
right: 0,
|
||||
marginTop: 6,
|
||||
background: 'white',
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.2)',
|
||||
padding: 4,
|
||||
zIndex: 50,
|
||||
minWidth: 150,
|
||||
}}
|
||||
>
|
||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
||||
<button
|
||||
key={lang.value}
|
||||
onClick={() => {
|
||||
useSettingsStore.setState((s) => ({ settings: { ...s.settings, language: lang.value } }));
|
||||
setShowLangPicker(false);
|
||||
}}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '6px 12px',
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
color: '#374151',
|
||||
borderRadius: 6,
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = '#f3f4f6')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'none')}
|
||||
>
|
||||
{lang.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Logo */}
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 44, height: 44, borderRadius: 12, background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(8px)', marginBottom: 12, border: '1px solid rgba(255,255,255,0.1)', position: 'relative' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
marginBottom: 12,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<img src="/icons/icon-white.svg" alt="TREK" width={26} height={26} />
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 600, letterSpacing: 3, textTransform: 'uppercase', opacity: 0.35, marginBottom: 12, position: 'relative' }}>{t('journey.public.tagline')}</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 600,
|
||||
letterSpacing: 3,
|
||||
textTransform: 'uppercase',
|
||||
opacity: 0.35,
|
||||
marginBottom: 12,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{t('journey.public.tagline')}
|
||||
</div>
|
||||
|
||||
<h1 className="relative" style={{ margin: '0 0 4px', fontSize: 'calc(26px * var(--fs-scale-title, 1))', fontWeight: 700, letterSpacing: -0.5 }}>{journey.title}</h1>
|
||||
<h1
|
||||
className="relative"
|
||||
style={{
|
||||
margin: '0 0 4px',
|
||||
fontSize: 'calc(26px * var(--fs-scale-title, 1))',
|
||||
fontWeight: 700,
|
||||
letterSpacing: -0.5,
|
||||
}}
|
||||
>
|
||||
{journey.title}
|
||||
</h1>
|
||||
|
||||
{journey.subtitle && (
|
||||
<div className="relative" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', opacity: 0.5, maxWidth: 400, margin: '0 auto', lineHeight: 1.5 }}>{journey.subtitle}</div>
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
opacity: 0.5,
|
||||
maxWidth: 400,
|
||||
margin: '0 auto',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{journey.subtitle}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats pill */}
|
||||
<div className="relative" style={{ marginTop: 12, display: 'inline-flex', alignItems: 'center', gap: 12, padding: '8px 18px', borderRadius: 20, background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(4px)', border: '1px solid rgba(255,255,255,0.08)' }}>
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><BookOpen size={12} /> {stats.entries} {t('journey.stats.entries')}</span>
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
marginTop: 12,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '8px 18px',
|
||||
borderRadius: 20,
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 500,
|
||||
opacity: 0.8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
}}
|
||||
>
|
||||
<BookOpen size={12} /> {stats.entries} {t('journey.stats.entries')}
|
||||
</span>
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', opacity: 0.4 }}>·</span>
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><Camera size={12} /> {stats.photos} {t('journey.stats.photos')}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 500,
|
||||
opacity: 0.8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
}}
|
||||
>
|
||||
<Camera size={12} /> {stats.photos} {t('journey.stats.photos')}
|
||||
</span>
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', opacity: 0.4 }}>·</span>
|
||||
<span style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500, opacity: 0.8, display: 'flex', alignItems: 'center', gap: 5 }}><MapPin size={12} /> {stats.places} {t('journey.stats.places')}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 500,
|
||||
opacity: 0.8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
}}
|
||||
>
|
||||
<MapPin size={12} /> {stats.places} {t('journey.stats.places')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative" style={{ marginTop: 12, fontSize: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 500, letterSpacing: 1.5, textTransform: 'uppercase', opacity: 0.25 }}>{t('journey.public.readOnly')}</div>
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
marginTop: 12,
|
||||
fontSize: 'calc(9px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 500,
|
||||
letterSpacing: 1.5,
|
||||
textTransform: 'uppercase',
|
||||
opacity: 0.25,
|
||||
}}
|
||||
>
|
||||
{t('journey.public.readOnly')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{desktopTwoColumn ? (
|
||||
// ── Desktop two-column: scrollable timeline feed + sticky map ──────────
|
||||
<div className="max-w-[1440px] mx-auto flex" style={{ alignItems: 'flex-start' }}>
|
||||
<div className="mx-auto flex max-w-[1440px]" style={{ alignItems: 'flex-start' }}>
|
||||
{/* Left: feed */}
|
||||
<div className="flex-1 xl:max-w-[50%] min-w-0 px-8 py-6">
|
||||
<div className="min-w-0 flex-1 px-8 py-6 xl:max-w-[50%]">
|
||||
{renderTabs(availableViews)}
|
||||
{view === 'timeline' && perms.share_timeline && renderTimeline()}
|
||||
{view === 'gallery' && perms.share_gallery && renderGallery()}
|
||||
@@ -407,13 +720,17 @@ export default function JourneyPublicPage() {
|
||||
<aside
|
||||
className="flex-shrink-0"
|
||||
style={{
|
||||
width: '44%', minWidth: 420, maxWidth: 760,
|
||||
position: 'sticky', top: 0, height: '100dvh',
|
||||
width: '44%',
|
||||
minWidth: 420,
|
||||
maxWidth: 760,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
height: '100dvh',
|
||||
padding: '16px 16px 16px 0',
|
||||
alignSelf: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div className="h-full rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-800 shadow-sm">
|
||||
<div className="h-full overflow-hidden rounded-2xl border border-zinc-200 shadow-sm dark:border-zinc-800">
|
||||
<JourneyMap
|
||||
ref={mapRef}
|
||||
checkins={[]}
|
||||
@@ -428,19 +745,21 @@ export default function JourneyPublicPage() {
|
||||
</div>
|
||||
) : (
|
||||
// ── Single-column layout (mobile + desktop-without-map) ───────────────
|
||||
<div className="max-w-[900px] mx-auto px-4 md:px-8 py-6">
|
||||
|
||||
<div className="mx-auto max-w-[900px] px-4 py-6 md:px-8">
|
||||
{/* Floating view toggle — visible above the fullscreen map on mobile */}
|
||||
{isMobile && view === 'timeline' && perms.share_timeline && perms.share_map && availableViews.length > 1 && (
|
||||
<div className="fixed left-0 right-0 z-50 flex justify-center px-4" style={{ top: 'calc(env(safe-area-inset-top, 0px) + 12px)' }}>
|
||||
<div className="flex bg-white/90 dark:bg-zinc-800/90 backdrop-blur-lg border border-zinc-200 dark:border-zinc-700 rounded-lg overflow-hidden shadow-lg">
|
||||
{availableViews.map(v => (
|
||||
<div
|
||||
className="fixed left-0 right-0 z-50 flex justify-center px-4"
|
||||
style={{ top: 'calc(env(safe-area-inset-top, 0px) + 12px)' }}
|
||||
>
|
||||
<div className="flex overflow-hidden rounded-lg border border-zinc-200 bg-white/90 shadow-lg backdrop-blur-lg dark:border-zinc-700 dark:bg-zinc-800/90">
|
||||
{availableViews.map((v) => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setView(v.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-[7px] text-[12px] font-medium ${
|
||||
view === v.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
|
||||
: 'text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
@@ -475,35 +794,43 @@ export default function JourneyPublicPage() {
|
||||
|
||||
{/* Map (standalone tab — only in single-column mode) */}
|
||||
{view === 'map' && perms.share_map && (
|
||||
<div className="rounded-2xl overflow-hidden border border-zinc-200 dark:border-zinc-700">
|
||||
<JourneyMap
|
||||
checkins={[]}
|
||||
entries={sidebarMapItems as any}
|
||||
height={500}
|
||||
/>
|
||||
<div className="overflow-hidden rounded-2xl border border-zinc-200 dark:border-zinc-700">
|
||||
<JourneyMap checkins={[]} entries={sidebarMapItems as any} height={500} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Powered by */}
|
||||
<div className="flex flex-col items-center py-8 gap-2">
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '8px 16px', borderRadius: 20, background: 'white', border: '1px solid #e5e7eb', boxShadow: '0 1px 3px rgba(0,0,0,0.04)' }}>
|
||||
<div className="flex flex-col items-center gap-2 py-8">
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '8px 16px',
|
||||
borderRadius: 20,
|
||||
background: 'white',
|
||||
border: '1px solid #e5e7eb',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04)',
|
||||
}}
|
||||
>
|
||||
<img src="/icons/icon.svg" alt="TREK" width={18} height={18} style={{ borderRadius: 4 }} />
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#9ca3af' }}>{t('journey.public.sharedVia')} <strong style={{ color: '#6b7280' }}>TREK</strong></span>
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#9ca3af' }}>
|
||||
{t('journey.public.sharedVia')} <strong style={{ color: '#6b7280' }}>TREK</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', color: '#d1d5db' }}>
|
||||
Made with <span style={{ color: '#ef4444' }}>♥</span> by Maurice · <a href="https://github.com/mauriceboe/TREK" style={{ color: '#9ca3af', textDecoration: 'none' }}>GitHub</a>
|
||||
Made with <span style={{ color: '#ef4444' }}>♥</span> by Maurice ·{' '}
|
||||
<a href="https://github.com/liketrek/TREK" style={{ color: '#9ca3af', textDecoration: 'none' }}>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lightbox */}
|
||||
{lightbox && (
|
||||
<PhotoLightbox
|
||||
photos={lightbox.photos}
|
||||
startIndex={lightbox.index}
|
||||
onClose={() => setLightbox(null)}
|
||||
/>
|
||||
<PhotoLightbox photos={lightbox.photos} startIndex={lightbox.index} onClose={() => setLightbox(null)} />
|
||||
)}
|
||||
|
||||
{/* Mobile entry detail view (public share) */}
|
||||
@@ -515,17 +842,19 @@ export default function JourneyPublicPage() {
|
||||
onClose={() => setViewingEntry(null)}
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
onPhotoClick={(photos, idx) => setLightbox({
|
||||
photos: photos.map(p => ({
|
||||
id: String(p.id),
|
||||
src: photoUrl(p as any, token!, 'original'),
|
||||
caption: (p as any).caption ?? null,
|
||||
mediaType: (p as any).media_type,
|
||||
})),
|
||||
index: idx,
|
||||
})}
|
||||
onPhotoClick={(photos, idx) =>
|
||||
setLightbox({
|
||||
photos: photos.map((p) => ({
|
||||
id: String(p.id),
|
||||
src: photoUrl(p as any, token!, 'original'),
|
||||
caption: (p as any).caption ?? null,
|
||||
mediaType: (p as any).media_type,
|
||||
})),
|
||||
index: idx,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
+1094
-369
File diff suppressed because it is too large
Load Diff
+983
-279
File diff suppressed because it is too large
Load Diff
@@ -1,29 +1,52 @@
|
||||
import React from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import Modal from '../../components/shared/Modal'
|
||||
import CustomSelect from '../../components/shared/CustomSelect'
|
||||
import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle, Fingerprint, Eye, EyeOff } from 'lucide-react'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import type { useAdmin } from './useAdmin'
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowUpCircle,
|
||||
CheckCircle,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Fingerprint,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { adminApi } from '../../api/client';
|
||||
import CustomSelect from '../../components/shared/CustomSelect';
|
||||
import Modal from '../../components/shared/Modal';
|
||||
import type { TranslationFn } from '../../types';
|
||||
import type { useAdmin } from './useAdmin';
|
||||
|
||||
interface AdminUserModalsProps {
|
||||
admin: ReturnType<typeof useAdmin>
|
||||
t: TranslationFn
|
||||
admin: ReturnType<typeof useAdmin>;
|
||||
t: TranslationFn;
|
||||
}
|
||||
|
||||
// The admin page's modal layer: create-user, edit-user, the "how to update"
|
||||
// popup and the rotate-JWT confirmation. Pure layout around the useAdmin hook.
|
||||
export default function AdminUserModals({ admin, t }: AdminUserModalsProps): React.ReactElement {
|
||||
const {
|
||||
logout, navigate, toast,
|
||||
editingUser, setEditingUser, editForm, setEditForm,
|
||||
showCreateUser, setShowCreateUser, createForm, setCreateForm,
|
||||
updateInfo, showUpdateModal, setShowUpdateModal,
|
||||
showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
|
||||
handleCreateUser, handleSaveUser,
|
||||
} = admin
|
||||
const [showCreatePw, setShowCreatePw] = React.useState(false)
|
||||
const [showEditPw, setShowEditPw] = React.useState(false)
|
||||
logout,
|
||||
navigate,
|
||||
toast,
|
||||
editingUser,
|
||||
setEditingUser,
|
||||
editForm,
|
||||
setEditForm,
|
||||
showCreateUser,
|
||||
setShowCreateUser,
|
||||
createForm,
|
||||
setCreateForm,
|
||||
updateInfo,
|
||||
showUpdateModal,
|
||||
setShowUpdateModal,
|
||||
showRotateJwtModal,
|
||||
setShowRotateJwtModal,
|
||||
rotatingJwt,
|
||||
setRotatingJwt,
|
||||
handleCreateUser,
|
||||
handleSaveUser,
|
||||
} = admin;
|
||||
const [showCreatePw, setShowCreatePw] = React.useState(false);
|
||||
const [showEditPw, setShowEditPw] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -34,16 +57,16 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
title={t('admin.createUser')}
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex gap-3 justify-end">
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowCreateUser(false)}
|
||||
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50"
|
||||
className="rounded-lg border border-slate-200 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateUser}
|
||||
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 text-white rounded-lg"
|
||||
className="rounded-lg bg-slate-900 px-4 py-2 text-sm text-white hover:bg-slate-700"
|
||||
>
|
||||
{t('admin.createUser')}
|
||||
</button>
|
||||
@@ -52,38 +75,38 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')} *</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{t('settings.username')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createForm.username}
|
||||
onChange={e => setCreateForm(f => ({ ...f, username: e.target.value }))}
|
||||
onChange={(e) => setCreateForm((f) => ({ ...f, username: e.target.value }))}
|
||||
placeholder={t('settings.username')}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2.5 text-sm text-slate-900 focus:border-transparent focus:ring-2 focus:ring-slate-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.email')} *</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{t('common.email')} *</label>
|
||||
<input
|
||||
type="email"
|
||||
value={createForm.email}
|
||||
onChange={e => setCreateForm(f => ({ ...f, email: e.target.value }))}
|
||||
onChange={(e) => setCreateForm((f) => ({ ...f, email: e.target.value }))}
|
||||
placeholder={t('common.email')}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2.5 text-sm text-slate-900 focus:border-transparent focus:ring-2 focus:ring-slate-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.password')} *</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{t('common.password')} *</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCreatePw ? 'text' : 'password'}
|
||||
value={createForm.password}
|
||||
onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))}
|
||||
onChange={(e) => setCreateForm((f) => ({ ...f, password: e.target.value }))}
|
||||
placeholder={t('common.password')}
|
||||
className="w-full px-3 py-2.5 pr-10 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2.5 pr-10 text-sm text-slate-900 focus:border-transparent focus:ring-2 focus:ring-slate-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreatePw(v => !v)}
|
||||
onClick={() => setShowCreatePw((v) => !v)}
|
||||
tabIndex={-1}
|
||||
aria-label="Show or hide password"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600"
|
||||
@@ -93,10 +116,10 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{t('settings.role')}</label>
|
||||
<CustomSelect
|
||||
value={createForm.role}
|
||||
onChange={value => setCreateForm(f => ({ ...f, role: String(value) }))}
|
||||
onChange={(value) => setCreateForm((f) => ({ ...f, role: String(value) }))}
|
||||
options={[
|
||||
{ value: 'user', label: t('settings.roleUser') },
|
||||
{ value: 'admin', label: t('settings.roleAdmin') },
|
||||
@@ -113,16 +136,16 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
title={t('admin.editUser')}
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex gap-3 justify-end">
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setEditingUser(null)}
|
||||
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50"
|
||||
className="rounded-lg border border-slate-200 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveUser}
|
||||
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 text-white rounded-lg"
|
||||
className="rounded-lg bg-slate-900 px-4 py-2 text-sm text-white hover:bg-slate-700"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
@@ -132,36 +155,39 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
{editingUser && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')}</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{t('settings.username')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editForm.username}
|
||||
onChange={e => setEditForm(f => ({ ...f, username: e.target.value }))}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, username: e.target.value }))}
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2.5 text-sm text-slate-900 focus:border-transparent focus:ring-2 focus:ring-slate-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.email')}</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{t('common.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={editForm.email}
|
||||
onChange={e => setEditForm(f => ({ ...f, email: e.target.value }))}
|
||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, email: e.target.value }))}
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2.5 text-sm text-slate-900 focus:border-transparent focus:ring-2 focus:ring-slate-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.newPassword')} <span className="text-slate-400 font-normal">({t('admin.newPasswordHint')})</span></label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">
|
||||
{t('admin.newPassword')}{' '}
|
||||
<span className="font-normal text-slate-400">({t('admin.newPasswordHint')})</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showEditPw ? 'text' : 'password'}
|
||||
value={editForm.password}
|
||||
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
|
||||
onChange={(e) => setEditForm((f) => ({ ...f, password: e.target.value }))}
|
||||
placeholder={t('admin.newPasswordPlaceholder')}
|
||||
className="w-full px-3 py-2.5 pr-10 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||
className="w-full rounded-lg border border-slate-300 px-3 py-2.5 pr-10 text-sm text-slate-900 focus:border-transparent focus:ring-2 focus:ring-slate-400"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEditPw(v => !v)}
|
||||
onClick={() => setShowEditPw((v) => !v)}
|
||||
tabIndex={-1}
|
||||
aria-label="Show or hide password"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600"
|
||||
@@ -171,31 +197,31 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{t('settings.role')}</label>
|
||||
<CustomSelect
|
||||
value={editForm.role}
|
||||
onChange={value => setEditForm(f => ({ ...f, role: String(value) }))}
|
||||
onChange={(value) => setEditForm((f) => ({ ...f, role: String(value) }))}
|
||||
options={[
|
||||
{ value: 'user', label: t('settings.roleUser') },
|
||||
{ value: 'admin', label: t('settings.roleAdmin') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-3 border-t border-slate-100">
|
||||
<p className="text-xs text-slate-400 mb-2">{t('admin.passkey.resetHint')}</p>
|
||||
<div className="border-t border-slate-100 pt-3">
|
||||
<p className="mb-2 text-xs text-slate-400">{t('admin.passkey.resetHint')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!editingUser) return
|
||||
if (!confirm(t('admin.passkey.resetConfirm', { name: editingUser.username }))) return
|
||||
if (!editingUser) return;
|
||||
if (!confirm(t('admin.passkey.resetConfirm', { name: editingUser.username }))) return;
|
||||
try {
|
||||
const r = await adminApi.resetUserPasskeys(editingUser.id)
|
||||
toast.success(t('admin.passkey.resetDone', { count: r.deleted ?? 0 }))
|
||||
const r = await adminApi.resetUserPasskeys(editingUser.id);
|
||||
toast.success(t('admin.passkey.resetDone', { count: r.deleted ?? 0 }));
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
toast.error(t('common.error'));
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50"
|
||||
className="flex items-center gap-2 rounded-lg border border-red-200 px-3 py-2 text-sm text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<Fingerprint size={14} /> {t('admin.passkey.reset')}
|
||||
</button>
|
||||
@@ -207,47 +233,110 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
||||
{/* Update instructions popup */}
|
||||
{showUpdateModal && (
|
||||
<div
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
}}
|
||||
onClick={() => setShowUpdateModal(false)}
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
|
||||
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
|
||||
className="border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div style={{ background: 'linear-gradient(135deg, #0f172a, #1e293b)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<div className="bg-[rgba(255,255,255,0.2)]" style={{ width: 40, height: 40, borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #0f172a, #1e293b)',
|
||||
padding: '20px 24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="bg-[rgba(255,255,255,0.2)]"
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<ArrowUpCircle size={20} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-white" style={{ margin: 0, fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700 }}>{t('admin.update.howTo')}</h3>
|
||||
<p className="text-[rgba(255,255,255,0.8)]" style={{ margin: '2px 0 0', fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>
|
||||
<h3
|
||||
className="text-white"
|
||||
style={{ margin: 0, fontSize: 'calc(16px * var(--fs-scale-subtitle, 1))', fontWeight: 700 }}
|
||||
>
|
||||
{t('admin.update.howTo')}
|
||||
</h3>
|
||||
<p
|
||||
className="text-[rgba(255,255,255,0.8)]"
|
||||
style={{ margin: '2px 0 0', fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}
|
||||
>
|
||||
v{updateInfo?.current} → v{updateInfo?.latest}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, margin: 0 }}>
|
||||
{(updateInfo?.is_docker === false ? t('admin.update.nonDockerText') : t('admin.update.dockerText')).replace('{version}', `v${updateInfo?.latest ?? ''}`)}
|
||||
<p
|
||||
className="text-gray-700 dark:text-gray-300"
|
||||
style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.6, margin: 0 }}
|
||||
>
|
||||
{(updateInfo?.is_docker === false
|
||||
? t('admin.update.nonDockerText')
|
||||
: t('admin.update.dockerText')
|
||||
).replace('{version}', `v${updateInfo?.latest ?? ''}`)}
|
||||
</p>
|
||||
|
||||
{updateInfo?.is_docker === false ? (
|
||||
<a
|
||||
href="https://github.com/mauriceboe/TREK/wiki/Updating"
|
||||
href="https://github.com/liketrek/TREK/wiki/Updating"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', lineHeight: 1.5, display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none' }}
|
||||
className="bg-gray-50 dark:bg-gray-900 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
style={{
|
||||
marginTop: 14,
|
||||
padding: '12px 14px',
|
||||
borderRadius: 10,
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
lineHeight: 1.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
className="border border-gray-200 bg-gray-50 text-gray-700 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4 flex-shrink-0" />
|
||||
<ExternalLink className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="font-semibold underline">{t('admin.update.wikiLink')}</span>
|
||||
</a>
|
||||
) : (
|
||||
<div style={{ marginTop: 14, padding: '12px 14px', borderRadius: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.8, fontFamily: 'monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
||||
className="bg-gray-900 dark:bg-gray-950 text-gray-100 border border-gray-700"
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
padding: '12px 14px',
|
||||
borderRadius: 10,
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
lineHeight: 1.8,
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
className="border border-gray-700 bg-gray-900 text-gray-100 dark:bg-gray-950"
|
||||
>
|
||||
{`docker pull mauriceboe/trek:latest
|
||||
{`docker pull mauriceboe/trek:latest
|
||||
docker stop trek && docker rm trek
|
||||
docker run -d --name trek \\
|
||||
-p 3000:3000 \\
|
||||
@@ -258,23 +347,42 @@ docker run -d --name trek \\
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.5 }}
|
||||
className="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 border border-emerald-200 dark:border-emerald-800"
|
||||
<div
|
||||
style={{
|
||||
marginTop: 10,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 10,
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
className="border border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||
<CheckCircle className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>{t('admin.update.dataInfo')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{updateInfo?.release_url && (
|
||||
<div style={{ marginTop: 10, padding: '10px 12px', borderRadius: 10, fontSize: 'calc(12px * var(--fs-scale-body, 1))', lineHeight: 1.5 }}
|
||||
className="bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800"
|
||||
<div
|
||||
style={{
|
||||
marginTop: 10,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 10,
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
className="border border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<ExternalLink className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
||||
<ExternalLink className="mt-0.5 h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>
|
||||
<a href={updateInfo.release_url} target="_blank" rel="noopener noreferrer" className="underline font-semibold">
|
||||
<a
|
||||
href={updateInfo.release_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold underline"
|
||||
>
|
||||
{t('admin.update.button')}
|
||||
</a>
|
||||
</span>
|
||||
@@ -286,8 +394,16 @@ docker run -d --name trek \\
|
||||
<div style={{ padding: '0 24px 20px', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => setShowUpdateModal(false)}
|
||||
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-700 dark:hover:bg-gray-200"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
className="bg-slate-900 text-white hover:bg-slate-700 dark:bg-white dark:text-slate-900 dark:hover:bg-gray-200"
|
||||
style={{
|
||||
padding: '9px 20px',
|
||||
borderRadius: 10,
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 600,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('common.close')}
|
||||
</button>
|
||||
@@ -303,46 +419,55 @@ docker run -d --name trek \\
|
||||
title="Rotate JWT Secret"
|
||||
size="sm"
|
||||
footer={
|
||||
<div className="flex gap-3 justify-end">
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowRotateJwtModal(false)}
|
||||
disabled={rotatingJwt}
|
||||
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-50"
|
||||
className="rounded-lg border border-slate-200 px-4 py-2 text-sm text-slate-600 hover:bg-slate-50 disabled:opacity-50"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setRotatingJwt(true)
|
||||
setRotatingJwt(true);
|
||||
try {
|
||||
await adminApi.rotateJwtSecret()
|
||||
setShowRotateJwtModal(false)
|
||||
logout()
|
||||
navigate('/login', { state: { noRedirect: true } })
|
||||
await adminApi.rotateJwtSecret();
|
||||
setShowRotateJwtModal(false);
|
||||
logout();
|
||||
navigate('/login', { state: { noRedirect: true } });
|
||||
} catch {
|
||||
toast.error(t('common.error'))
|
||||
setRotatingJwt(false)
|
||||
toast.error(t('common.error'));
|
||||
setRotatingJwt(false);
|
||||
}
|
||||
}}
|
||||
disabled={rotatingJwt}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm bg-red-600 hover:bg-red-700 disabled:bg-red-300 text-white rounded-lg font-medium"
|
||||
className="flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:bg-red-300"
|
||||
>
|
||||
{rotatingJwt ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||
{rotatingJwt ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
Rotate & Log out
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-red-100">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900 mb-1">Warning, this will invalidate all sessions and log you out.</p>
|
||||
<p className="text-xs text-slate-500">A new JWT secret will be generated immediately. Every logged-in user — including you — will be signed out and will need to log in again.</p>
|
||||
<p className="mb-1 text-sm font-medium text-slate-900">
|
||||
Warning, this will invalidate all sessions and log you out.
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
A new JWT secret will be generated immediately. Every logged-in user — including you — will be signed out
|
||||
and will need to log in again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# trek-plugin-sdk
|
||||
|
||||
The SDK for building [TREK](https://github.com/mauriceboe/TREK) plugins.
|
||||
The SDK for building [TREK](https://github.com/liketrek/TREK) plugins.
|
||||
|
||||
The path is four commands: **`create` → `dev` → `status` → `publish`**. Everything
|
||||
else the CLI can do is a step one of those already does for you.
|
||||
@@ -81,7 +81,7 @@ no-transparency) and auto-reports your height. It also upgrades any native
|
||||
`<select>` into a host-styled, keyboard-accessible dropdown that matches TREK —
|
||||
the OS-drawn popup never could. Write a plain `<select>` and it just works; add
|
||||
`data-trek-native` to opt a field out. See the
|
||||
[Plugin Development wiki](https://github.com/mauriceboe/TREK/wiki/Plugin-Development)
|
||||
[Plugin Development wiki](https://github.com/liketrek/TREK/wiki/Plugin-Development)
|
||||
for the full component + token reference.
|
||||
|
||||
## Write a plugin
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"apiVersion": 1,
|
||||
"author": "TREK",
|
||||
"description": "Koffi, the tiny suitcase mascot, lives on your boarding pass — strolling, rolling, collecting stickers, and getting more excited the closer your trip gets.",
|
||||
"homepage": "https://github.com/mauriceboe/trek-plugin-koffi",
|
||||
"homepage": "https://github.com/liketrek/TREK-plugin-koffi",
|
||||
"license": "MIT",
|
||||
"icon": "Luggage",
|
||||
"type": "widget",
|
||||
|
||||
@@ -37,5 +37,5 @@ npx @trek/plugin-sdk dev # hot-reload against a local TREK
|
||||
npx @trek/plugin-sdk pack # build a signed .trekplugin bundle
|
||||
```
|
||||
|
||||
See the [Plugin Cookbook](https://github.com/mauriceboe/TREK/wiki/Plugin-Cookbook)
|
||||
See the [Plugin Cookbook](https://github.com/liketrek/TREK/wiki/Plugin-Cookbook)
|
||||
for the individual recipes this example is built from.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"apiVersion": 1,
|
||||
"author": "TREK",
|
||||
"description": "Checks a trip for common gaps (places with no map location, no bookings yet) and lets you pin a private note on any place. A hooks-only plugin — it has no UI of its own; TREK renders everything natively.",
|
||||
"homepage": "https://github.com/mauriceboe/trek-plugin-trip-doctor",
|
||||
"homepage": "https://github.com/liketrek/TREK-plugin-trip-doctor",
|
||||
"license": "MIT",
|
||||
"icon": "Stethoscope",
|
||||
"type": "integration",
|
||||
|
||||
@@ -15,14 +15,14 @@
|
||||
"url": "https://github.com/jubnl"
|
||||
}
|
||||
],
|
||||
"homepage": "https://github.com/mauriceboe/TREK/wiki/Plugin-Development",
|
||||
"homepage": "https://github.com/liketrek/TREK/wiki/Plugin-Development",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/mauriceboe/TREK.git",
|
||||
"url": "git+https://github.com/liketrek/TREK.git",
|
||||
"directory": "plugin-sdk"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/mauriceboe/TREK/issues"
|
||||
"url": "https://github.com/liketrek/TREK/issues"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
||||
@@ -741,7 +741,7 @@ describe('pack + entry (publishing automation)', () => {
|
||||
// "requires 3.2.0+" and considered the plugin compatible.
|
||||
expect(v.trek).toBe('>=3.2.0 <4.0.0');
|
||||
expect(v).not.toHaveProperty('minTrekVersion');
|
||||
expect(v.downloadUrl).toBe('https://github.com/mauriceboe/trek-plugin-koffi/releases/download/v1.0.0/plugin.zip');
|
||||
expect(v.downloadUrl).toBe('https://github.com/liketrek/TREK-plugin-koffi/releases/download/v1.0.0/plugin.zip');
|
||||
expect(v.nativeModules).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
+576
-168
File diff suppressed because it is too large
Load Diff
+283
-161
@@ -1,7 +1,9 @@
|
||||
import { db } from '../db/database';
|
||||
import { decrypt_api_key } from './apiKeyCrypto';
|
||||
import { safeFetchFollow, SsrfBlockedError } from '../utils/ssrfGuard';
|
||||
import { decrypt_api_key } from './apiKeyCrypto';
|
||||
import { getAppUrl } from './notifications';
|
||||
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
|
||||
import * as placePhotoCache from './placePhotoCache';
|
||||
|
||||
// ── Google API call counter ───────────────────────────────────────────────────
|
||||
|
||||
@@ -13,7 +15,7 @@ function googleFetch(endpoint: string, label: string, init?: RequestInit): Promi
|
||||
const referer = process.env.APP_URL ? getAppUrl() : undefined;
|
||||
return fetch(endpoint, {
|
||||
...init,
|
||||
headers: { ...(referer ? { Referer: referer } : {}), ...(init?.headers as Record<string, string> ?? {}) },
|
||||
headers: { ...(referer ? { Referer: referer } : {}), ...((init?.headers as Record<string, string>) ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,7 +64,12 @@ interface GooglePlaceDetails extends GooglePlaceResult {
|
||||
userRatingCount?: number;
|
||||
regularOpeningHours?: { weekdayDescriptions?: string[]; openNow?: boolean };
|
||||
editorialSummary?: { text: string };
|
||||
reviews?: { authorAttribution?: { displayName?: string; photoUri?: string }; rating?: number; text?: { text?: string }; relativePublishTimeDescription?: string }[];
|
||||
reviews?: {
|
||||
authorAttribution?: { displayName?: string; photoUri?: string };
|
||||
rating?: number;
|
||||
text?: { text?: string };
|
||||
relativePublishTimeDescription?: string;
|
||||
}[];
|
||||
photos?: { name: string; authorAttributions?: { displayName?: string }[] }[];
|
||||
}
|
||||
|
||||
@@ -73,7 +80,7 @@ interface GooglePlaceDetails extends GooglePlaceResult {
|
||||
// and throttled harder (see #1309). When the instance URL is configured we append it;
|
||||
// getAppUrl()'s bare http://localhost fallback isn't a useful identifier, so we drop it.
|
||||
export function buildUserAgent(instanceUrl: string | undefined): string {
|
||||
const base = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
|
||||
const base = 'TREK Travel Planner (https://github.com/liketrek/TREK)';
|
||||
if (instanceUrl && !instanceUrl.startsWith('http://localhost')) return `${base}; ${instanceUrl}`;
|
||||
return base;
|
||||
}
|
||||
@@ -115,9 +122,6 @@ export function googleFtidFromMapsUrl(url?: string | null): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
|
||||
import * as placePhotoCache from './placePhotoCache';
|
||||
|
||||
// ── Concurrency limiter for outbound photo fetches ───────────────────────────
|
||||
// Caps simultaneous Wikimedia/Google photo requests so a bulk import of hundreds
|
||||
// of places cannot monopolise the event loop or trigger external API rate limits.
|
||||
@@ -130,7 +134,7 @@ function acquirePhotoFetchSlot(): Promise<void> {
|
||||
photoFetchActive++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise(resolve => photoFetchQueue.push(resolve));
|
||||
return new Promise((resolve) => photoFetchQueue.push(resolve));
|
||||
}
|
||||
|
||||
function releasePhotoFetchSlot(): void {
|
||||
@@ -145,10 +149,16 @@ function releasePhotoFetchSlot(): void {
|
||||
// ── API key retrieval ────────────────────────────────────────────────────────
|
||||
|
||||
export function getMapsKey(userId: number): string | null {
|
||||
const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(userId) as { maps_api_key: string | null } | undefined;
|
||||
const user = db.prepare('SELECT maps_api_key FROM users WHERE id = ?').get(userId) as
|
||||
| { maps_api_key: string | null }
|
||||
| undefined;
|
||||
const user_key = decrypt_api_key(user?.maps_api_key);
|
||||
if (user_key) return user_key;
|
||||
const admin = db.prepare("SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1").get() as { maps_api_key: string } | undefined;
|
||||
const admin = db
|
||||
.prepare(
|
||||
"SELECT maps_api_key FROM users WHERE role = 'admin' AND maps_api_key IS NOT NULL AND maps_api_key != '' LIMIT 1",
|
||||
)
|
||||
.get() as { maps_api_key: string } | undefined;
|
||||
return decrypt_api_key(admin?.maps_api_key) || null;
|
||||
}
|
||||
|
||||
@@ -167,10 +177,12 @@ export async function searchNominatim(query: string, lang?: string) {
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
throw new Error(`Nominatim API error: ${response.status} ${response.statusText}${text ? ' - ' + text.substring(0, 200) : ''}`);
|
||||
throw new Error(
|
||||
`Nominatim API error: ${response.status} ${response.statusText}${text ? ' - ' + text.substring(0, 200) : ''}`,
|
||||
);
|
||||
}
|
||||
const data = await response.json() as NominatimResult[];
|
||||
return data.map(item => ({
|
||||
const data = (await response.json()) as NominatimResult[];
|
||||
return data.map((item) => ({
|
||||
google_place_id: null,
|
||||
google_ftid: null,
|
||||
osm_id: `${item.osm_type}:${item.osm_id}`,
|
||||
@@ -187,8 +199,15 @@ export async function searchNominatim(query: string, lang?: string) {
|
||||
|
||||
// ── Nominatim lookup (by OSM ID) ────────────────────────────────────────────
|
||||
|
||||
export async function lookupNominatim(osmType: string, osmId: string, lang?: string): Promise<{
|
||||
name: string; address: string; lat: number | null; lng: number | null;
|
||||
export async function lookupNominatim(
|
||||
osmType: string,
|
||||
osmId: string,
|
||||
lang?: string,
|
||||
): Promise<{
|
||||
name: string;
|
||||
address: string;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
} | null> {
|
||||
const typePrefix = osmType.charAt(0).toUpperCase(); // N, W, R
|
||||
const params = new URLSearchParams({
|
||||
@@ -201,7 +220,7 @@ export async function lookupNominatim(osmType: string, osmId: string, lang?: str
|
||||
headers: { 'User-Agent': UA },
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as NominatimResult[];
|
||||
const data = (await res.json()) as NominatimResult[];
|
||||
const item = data[0];
|
||||
if (!item) return null;
|
||||
return {
|
||||
@@ -210,7 +229,9 @@ export async function lookupNominatim(osmType: string, osmId: string, lang?: str
|
||||
lat: parseFloat(item.lat) || null,
|
||||
lng: parseFloat(item.lon) || null,
|
||||
};
|
||||
} catch { return null; }
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Overpass API (OSM details) ───────────────────────────────────────────────
|
||||
@@ -227,9 +248,11 @@ export async function fetchOverpassDetails(osmType: string, osmId: string): Prom
|
||||
body: `data=${encodeURIComponent(query)}`,
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as { elements?: OverpassElement[] };
|
||||
const data = (await res.json()) as { elements?: OverpassElement[] };
|
||||
return data.elements?.[0] || null;
|
||||
} catch { return null; }
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Overpass POI search (by category within a viewport bbox) ─────────────────
|
||||
@@ -259,7 +282,14 @@ const CATEGORY_OSM_FILTERS: Record<string, string[]> = {
|
||||
cafe: ['amenity=cafe'],
|
||||
bar: ['amenity=bar', 'amenity=pub', 'amenity=nightclub'],
|
||||
hotel: ['tourism=hotel', 'tourism=hostel', 'tourism=guest_house', 'tourism=apartment', 'tourism=motel'],
|
||||
sights: ['tourism=attraction', 'tourism=viewpoint', 'historic=monument', 'historic=castle', 'historic=memorial', 'historic=ruins'],
|
||||
sights: [
|
||||
'tourism=attraction',
|
||||
'tourism=viewpoint',
|
||||
'historic=monument',
|
||||
'historic=castle',
|
||||
'historic=memorial',
|
||||
'historic=ruins',
|
||||
],
|
||||
museum: ['tourism=museum', 'tourism=gallery', 'tourism=artwork', 'amenity=theatre'],
|
||||
nature: ['leisure=park', 'leisure=garden', 'natural=beach', 'natural=peak'],
|
||||
activity: ['tourism=theme_park', 'tourism=zoo', 'tourism=aquarium', 'leisure=water_park'],
|
||||
@@ -306,10 +336,14 @@ const DEFAULT_OVERPASS_MIRRORS = [
|
||||
export function resolveOverpassEndpoints(raw: string | undefined = process.env.OVERPASS_URL): string[] {
|
||||
const custom = (raw ?? '')
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(s => {
|
||||
try { const u = new URL(s); return u.protocol === 'http:' || u.protocol === 'https:'; }
|
||||
catch { return false; }
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => {
|
||||
try {
|
||||
const u = new URL(s);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
return custom.length ? custom : DEFAULT_OVERPASS_MIRRORS;
|
||||
}
|
||||
@@ -358,7 +392,7 @@ async function overpassFetch(query: string): Promise<OverpassPoiElement[]> {
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`Overpass ${res.status} @ ${url}`);
|
||||
const data = await res.json() as { elements?: OverpassPoiElement[]; remark?: string };
|
||||
const data = (await res.json()) as { elements?: OverpassPoiElement[]; remark?: string };
|
||||
// Overpass signals an internal timeout / runtime error via `remark` while
|
||||
// still answering HTTP 200 — often fast, with an empty or partial element
|
||||
// set. Treat that as a failed attempt so a healthy mirror wins the race
|
||||
@@ -380,14 +414,23 @@ async function overpassFetch(query: string): Promise<OverpassPoiElement[]> {
|
||||
// Log WHY every endpoint failed (connection refused, aborted/timed out, non-OSM
|
||||
// body, …) so an operator can tell blocked egress / a firewall from a transiently
|
||||
// overloaded mirror — otherwise this is a bare 502 with no breadcrumb (see #1309).
|
||||
const reasons = err instanceof AggregateError
|
||||
? err.errors.map(e => (e instanceof Error ? e.message : String(e))).join(' | ')
|
||||
: (err instanceof Error ? err.message : String(err));
|
||||
const reasons =
|
||||
err instanceof AggregateError
|
||||
? err.errors.map((e) => (e instanceof Error ? e.message : String(e))).join(' | ')
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
console.error(`[Overpass] all ${OVERPASS_MIRRORS.length} endpoint(s) failed — ${reasons}`);
|
||||
throw Object.assign(new Error('Could not reach any Overpass endpoint'), { status: 502 });
|
||||
} finally {
|
||||
// Cancel the slower/losing requests — we already have (or have given up on) a result.
|
||||
controllers.forEach(c => { try { c.abort(); } catch { /* noop */ } });
|
||||
controllers.forEach((c) => {
|
||||
try {
|
||||
c.abort();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,10 +467,12 @@ export async function searchOverpassPois(
|
||||
|
||||
// Overpass wants the box as (south,west,north,east) = (minLat,minLng,maxLat,maxLng).
|
||||
const box = `(${south},${west},${north},${east})`;
|
||||
const selectors = filters.map(f => {
|
||||
const [k, v] = f.split('=');
|
||||
return ` nwr["${k}"="${v}"]${box};`;
|
||||
}).join('\n');
|
||||
const selectors = filters
|
||||
.map((f) => {
|
||||
const [k, v] = f.split('=');
|
||||
return ` nwr["${k}"="${v}"]${box};`;
|
||||
})
|
||||
.join('\n');
|
||||
// `out center tags <n>` returns ways/relations with a computed center and caps
|
||||
// the result count in one round-trip.
|
||||
const query = `[out:json][timeout:20];\n(\n${selectors}\n);\nout center tags ${limit + 25};`;
|
||||
@@ -442,8 +487,15 @@ export async function searchOverpassPois(
|
||||
const lat = el.lat ?? el.center?.lat;
|
||||
const lng = el.lon ?? el.center?.lon;
|
||||
if (lat == null || lng == null) continue;
|
||||
const matched = filters.find(f => { const [k, v] = f.split('='); return tags[k] === v; }) || filters[0];
|
||||
const addr = [tags['addr:street'], tags['addr:housenumber'], tags['addr:postcode'], tags['addr:city']].filter(Boolean).join(' ') || null;
|
||||
const matched =
|
||||
filters.find((f) => {
|
||||
const [k, v] = f.split('=');
|
||||
return tags[k] === v;
|
||||
}) || filters[0];
|
||||
const addr =
|
||||
[tags['addr:street'], tags['addr:housenumber'], tags['addr:postcode'], tags['addr:city']]
|
||||
.filter(Boolean)
|
||||
.join(' ') || null;
|
||||
pois.push({
|
||||
osm_id: `${el.type}:${el.id}`,
|
||||
name,
|
||||
@@ -472,18 +524,23 @@ export async function searchOverpassPois(
|
||||
export function parseOpeningHours(ohString: string): { weekdayDescriptions: string[]; openNow: boolean | null } {
|
||||
const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
||||
const LONG = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
const result: string[] = LONG.map(d => `${d}: ?`);
|
||||
const result: string[] = LONG.map((d) => `${d}: ?`);
|
||||
|
||||
// Parse segments like "Mo-Fr 09:00-18:00; Sa 10:00-14:00"
|
||||
for (const segment of ohString.split(';')) {
|
||||
const trimmed = segment.trim();
|
||||
if (!trimmed) continue;
|
||||
const match = trimmed.match(/^((?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?(?:\s*,\s*(?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?)*)\s+(.+)$/i);
|
||||
const match = trimmed.match(
|
||||
/^((?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?(?:\s*,\s*(?:Mo|Tu|We|Th|Fr|Sa|Su)(?:\s*-\s*(?:Mo|Tu|We|Th|Fr|Sa|Su))?)*)\s+(.+)$/i,
|
||||
);
|
||||
if (!match) continue;
|
||||
const [, daysPart, timePart] = match;
|
||||
const dayIndices = new Set<number>();
|
||||
for (const range of daysPart.split(',')) {
|
||||
const parts = range.trim().split('-').map(d => DAYS.indexOf(d.trim()));
|
||||
const parts = range
|
||||
.trim()
|
||||
.split('-')
|
||||
.map((d) => DAYS.indexOf(d.trim()));
|
||||
if (parts.length === 2 && parts[0] >= 0 && parts[1] >= 0) {
|
||||
for (let i = parts[0]; i !== (parts[1] + 1) % 7; i = (i + 1) % 7) dayIndices.add(i);
|
||||
dayIndices.add(parts[1]);
|
||||
@@ -506,13 +563,15 @@ export function parseOpeningHours(ohString: string): { weekdayDescriptions: stri
|
||||
const timeRanges = [...todayLine.matchAll(/(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})/g)];
|
||||
if (timeRanges.length > 0) {
|
||||
const nowMins = now.getHours() * 60 + now.getMinutes();
|
||||
openNow = timeRanges.some(m => {
|
||||
openNow = timeRanges.some((m) => {
|
||||
const start = parseInt(m[1]) * 60 + parseInt(m[2]);
|
||||
const end = parseInt(m[3]) * 60 + parseInt(m[4]);
|
||||
return end > start ? nowMins >= start && nowMins < end : nowMins >= start || nowMins < end;
|
||||
});
|
||||
}
|
||||
} catch { /* best effort */ }
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
|
||||
return { weekdayDescriptions: result, openNow };
|
||||
}
|
||||
@@ -524,7 +583,7 @@ export function buildOsmDetails(tags: Record<string, string>, osmType: string, o
|
||||
let open_now: boolean | null = null;
|
||||
if (tags.opening_hours) {
|
||||
const parsed = parseOpeningHours(tags.opening_hours);
|
||||
const hasData = parsed.weekdayDescriptions.some(line => !line.endsWith('?'));
|
||||
const hasData = parsed.weekdayDescriptions.some((line) => !line.endsWith('?'));
|
||||
if (hasData) {
|
||||
opening_hours = parsed.weekdayDescriptions;
|
||||
open_now = parsed.openNow;
|
||||
@@ -543,12 +602,17 @@ export function buildOsmDetails(tags: Record<string, string>, osmType: string, o
|
||||
|
||||
// ── Wikimedia Commons photo lookup ───────────────────────────────────────────
|
||||
|
||||
export async function fetchWikimediaPhoto(lat: number, lng: number, name?: string): Promise<{ photoUrl: string; attribution: string | null } | null> {
|
||||
export async function fetchWikimediaPhoto(
|
||||
lat: number,
|
||||
lng: number,
|
||||
name?: string,
|
||||
): Promise<{ photoUrl: string; attribution: string | null } | null> {
|
||||
// Strategy 1: Search Wikipedia for the place name -> get the article image
|
||||
if (name) {
|
||||
try {
|
||||
const searchParams = new URLSearchParams({
|
||||
action: 'query', format: 'json',
|
||||
action: 'query',
|
||||
format: 'json',
|
||||
titles: name,
|
||||
prop: 'pageimages',
|
||||
piprop: 'thumbnail',
|
||||
@@ -558,7 +622,7 @@ export async function fetchWikimediaPhoto(lat: number, lng: number, name?: strin
|
||||
});
|
||||
const res = await fetch(`https://en.wikipedia.org/w/api.php?${searchParams}`, { headers: { 'User-Agent': UA } });
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { query?: { pages?: Record<string, { thumbnail?: { source?: string } }> } };
|
||||
const data = (await res.json()) as { query?: { pages?: Record<string, { thumbnail?: { source?: string } }> } };
|
||||
const pages = data.query?.pages;
|
||||
if (pages) {
|
||||
for (const page of Object.values(pages)) {
|
||||
@@ -568,12 +632,15 @@ export async function fetchWikimediaPhoto(lat: number, lng: number, name?: strin
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* fall through to geosearch */ }
|
||||
} catch {
|
||||
/* fall through to geosearch */
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Wikimedia Commons geosearch by coordinates
|
||||
const params = new URLSearchParams({
|
||||
action: 'query', format: 'json',
|
||||
action: 'query',
|
||||
format: 'json',
|
||||
generator: 'geosearch',
|
||||
ggsprimary: 'all',
|
||||
ggsnamespace: '6',
|
||||
@@ -587,7 +654,9 @@ export async function fetchWikimediaPhoto(lat: number, lng: number, name?: strin
|
||||
try {
|
||||
const res = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json() as { query?: { pages?: Record<string, WikiCommonsPage & { imageinfo?: { mime?: string }[] }> } };
|
||||
const data = (await res.json()) as {
|
||||
query?: { pages?: Record<string, WikiCommonsPage & { imageinfo?: { mime?: string }[] }> };
|
||||
};
|
||||
const pages = data.query?.pages;
|
||||
if (!pages) return null;
|
||||
for (const page of Object.values(pages)) {
|
||||
@@ -602,12 +671,19 @@ export async function fetchWikimediaPhoto(lat: number, lng: number, name?: strin
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch { return null; }
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Search places (Google or Nominatim fallback) ─────────────────────────────
|
||||
|
||||
export async function searchPlaces(userId: number, query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }): Promise<{ places: Record<string, unknown>[]; source: string }> {
|
||||
export async function searchPlaces(
|
||||
userId: number,
|
||||
query: string,
|
||||
lang?: string,
|
||||
locationBias?: { lat: number; lng: number; radius?: number },
|
||||
): Promise<{ places: Record<string, unknown>[]; source: string }> {
|
||||
const apiKey = getMapsKey(userId);
|
||||
|
||||
if (!apiKey) {
|
||||
@@ -632,12 +708,13 @@ export async function searchPlaces(userId: number, query: string, lang?: string,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri',
|
||||
'X-Goog-FieldMask':
|
||||
'places.id,places.displayName,places.formattedAddress,places.location,places.rating,places.websiteUri,places.nationalPhoneNumber,places.types,places.googleMapsUri',
|
||||
},
|
||||
body: JSON.stringify(searchBody),
|
||||
});
|
||||
|
||||
const data = await response.json() as { places?: GooglePlaceResult[]; error?: { message?: string } };
|
||||
const data = (await response.json()) as { places?: GooglePlaceResult[]; error?: { message?: string } };
|
||||
|
||||
if (!response.ok) {
|
||||
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
|
||||
@@ -698,7 +775,10 @@ export async function autocompletePlaces(
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await response.json() as { suggestions?: GoogleAutocompleteSuggestion[]; error?: { message?: string } };
|
||||
const data = (await response.json()) as {
|
||||
suggestions?: GoogleAutocompleteSuggestion[];
|
||||
error?: { message?: string };
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
const err = new Error(data.error?.message || 'Google Places Autocomplete error') as Error & { status: number };
|
||||
@@ -744,7 +824,11 @@ async function autocompleteNominatim(
|
||||
|
||||
// ── Place details (Google or OSM) ────────────────────────────────────────────
|
||||
|
||||
export async function getPlaceDetails(userId: number, placeId: string, lang?: string): Promise<{ place: Record<string, unknown> }> {
|
||||
export async function getPlaceDetails(
|
||||
userId: number,
|
||||
placeId: string,
|
||||
lang?: string,
|
||||
): Promise<{ place: Record<string, unknown> }> {
|
||||
// OSM details: placeId is "node:123456" or "way:123456" etc.
|
||||
if (placeId.includes(':')) {
|
||||
const [osmType, osmId] = placeId.split(':');
|
||||
@@ -777,20 +861,27 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
|
||||
|
||||
// Check DB cache first (lean mask, expanded=0) — 7-day TTL
|
||||
const DETAILS_TTL = 7 * 24 * 60 * 60 * 1000;
|
||||
const cached = db.prepare(
|
||||
'SELECT payload_json, fetched_at FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 0'
|
||||
).get(placeId, langKey) as { payload_json: string; fetched_at: number } | undefined;
|
||||
const cached = db
|
||||
.prepare(
|
||||
'SELECT payload_json, fetched_at FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 0',
|
||||
)
|
||||
.get(placeId, langKey) as { payload_json: string; fetched_at: number } | undefined;
|
||||
if (cached && Date.now() - cached.fetched_at < DETAILS_TTL) return { place: JSON.parse(cached.payload_json) };
|
||||
|
||||
const response = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, `getPlaceDetails(${placeId})`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri',
|
||||
const response = await googleFetch(
|
||||
`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`,
|
||||
`getPlaceDetails(${placeId})`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask':
|
||||
'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri',
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
const data = await response.json() as GooglePlaceDetails & { error?: { message?: string } };
|
||||
const data = (await response.json()) as GooglePlaceDetails & { error?: { message?: string } };
|
||||
|
||||
if (!response.ok) {
|
||||
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
|
||||
@@ -820,7 +911,7 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 0, ?, ?)'
|
||||
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 0, ?, ?)',
|
||||
).run(placeId, langKey, JSON.stringify(place), Date.now());
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to cache place details:', dbErr);
|
||||
@@ -829,28 +920,38 @@ export async function getPlaceDetails(userId: number, placeId: string, lang?: st
|
||||
return { place };
|
||||
}
|
||||
|
||||
export async function getPlaceDetailsExpanded(userId: number, placeId: string, lang?: string, refresh = false): Promise<{ place: Record<string, unknown> }> {
|
||||
export async function getPlaceDetailsExpanded(
|
||||
userId: number,
|
||||
placeId: string,
|
||||
lang?: string,
|
||||
refresh = false,
|
||||
): Promise<{ place: Record<string, unknown> }> {
|
||||
const langKey = toApiLang(lang, 'de');
|
||||
const apiKey = getMapsKey(userId);
|
||||
if (!apiKey) throw Object.assign(new Error('Google Maps API key not configured'), { status: 400 });
|
||||
|
||||
// Check DB cache for expanded result
|
||||
if (!refresh) {
|
||||
const cached = db.prepare(
|
||||
'SELECT payload_json FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 1'
|
||||
).get(placeId, langKey) as { payload_json: string } | undefined;
|
||||
const cached = db
|
||||
.prepare('SELECT payload_json FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 1')
|
||||
.get(placeId, langKey) as { payload_json: string } | undefined;
|
||||
if (cached) return { place: JSON.parse(cached.payload_json) };
|
||||
}
|
||||
|
||||
const response = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`, `getPlaceDetailsExpanded(${placeId})`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri,reviews,editorialSummary',
|
||||
const response = await googleFetch(
|
||||
`https://places.googleapis.com/v1/places/${placeId}?languageCode=${langKey}`,
|
||||
`getPlaceDetailsExpanded(${placeId})`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask':
|
||||
'id,displayName,formattedAddress,location,rating,userRatingCount,websiteUri,nationalPhoneNumber,regularOpeningHours,googleMapsUri,reviews,editorialSummary',
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
const data = await response.json() as GooglePlaceDetails & { error?: { message?: string } };
|
||||
const data = (await response.json()) as GooglePlaceDetails & { error?: { message?: string } };
|
||||
|
||||
if (!response.ok) {
|
||||
const err = new Error(data.error?.message || 'Google Places API error') as Error & { status: number };
|
||||
@@ -886,7 +987,7 @@ export async function getPlaceDetailsExpanded(userId: number, placeId: string, l
|
||||
|
||||
try {
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 1, ?, ?)'
|
||||
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 1, ?, ?)',
|
||||
).run(placeId, langKey, JSON.stringify(place), Date.now());
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to cache expanded place details:', dbErr);
|
||||
@@ -924,96 +1025,103 @@ export async function getPlacePhoto(
|
||||
const fetchPromise = (async (): Promise<{ filePath: string; attribution: string | null } | null> => {
|
||||
await acquirePhotoFetchSlot();
|
||||
try {
|
||||
const apiKey = getMapsKey(userId);
|
||||
const isCoordLookup = placeId.startsWith('coords:');
|
||||
const apiKey = getMapsKey(userId);
|
||||
const isCoordLookup = placeId.startsWith('coords:');
|
||||
|
||||
// Coordinate-based Wikipedia/Wikimedia lookup. Used for coordinate-only
|
||||
// (right-click) places and as a fallback when a Google place yields no photo,
|
||||
// so a place added via search still gets a marker image when Google returns
|
||||
// nothing. Returns null (without marking an error) so the caller decides.
|
||||
const fetchWikimediaFallback = async (): Promise<{ filePath: string; attribution: string | null } | null> => {
|
||||
if (isNaN(lat) || isNaN(lng)) return null;
|
||||
try {
|
||||
const wiki = await fetchWikimediaPhoto(lat, lng, name);
|
||||
if (!wiki) return null;
|
||||
// Follow redirects manually so each hop (the image URL can 3xx to a CDN
|
||||
// host) is re-validated against the SSRF guard, not just the first URL.
|
||||
const imgRes = await safeFetchFollow(wiki.photoUrl, undefined, { bypassInternalIpAllowed: true });
|
||||
if (!imgRes.ok) return null;
|
||||
const bytes = Buffer.from(await imgRes.arrayBuffer());
|
||||
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
|
||||
return { filePath: cached.filePath, attribution: cached.attribution };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
// Coordinate-based Wikipedia/Wikimedia lookup. Used for coordinate-only
|
||||
// (right-click) places and as a fallback when a Google place yields no photo,
|
||||
// so a place added via search still gets a marker image when Google returns
|
||||
// nothing. Returns null (without marking an error) so the caller decides.
|
||||
const fetchWikimediaFallback = async (): Promise<{ filePath: string; attribution: string | null } | null> => {
|
||||
if (isNaN(lat) || isNaN(lng)) return null;
|
||||
try {
|
||||
const wiki = await fetchWikimediaPhoto(lat, lng, name);
|
||||
if (!wiki) return null;
|
||||
// Follow redirects manually so each hop (the image URL can 3xx to a CDN
|
||||
// host) is re-validated against the SSRF guard, not just the first URL.
|
||||
const imgRes = await safeFetchFollow(wiki.photoUrl, undefined, { bypassInternalIpAllowed: true });
|
||||
if (!imgRes.ok) return null;
|
||||
const bytes = Buffer.from(await imgRes.arrayBuffer());
|
||||
const cached = await placePhotoCache.put(placeId, bytes, wiki.attribution);
|
||||
return { filePath: cached.filePath, attribution: cached.attribution };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Google Places photo for a Google place_id. Returns null (without marking an
|
||||
// error) on any miss — no key, URL-shaped id, request rejected, no photos, or
|
||||
// a failed media download — so the caller can fall back to Wikimedia.
|
||||
const fetchGooglePhoto = async (): Promise<{ filePath: string; attribution: string | null } | null> => {
|
||||
// URL-shaped placeIds aren't Google IDs — legacy DBs may store raw photo URLs in image_url
|
||||
if (!apiKey || /^https?:\/\//i.test(placeId)) return null;
|
||||
// Google Places photo for a Google place_id. Returns null (without marking an
|
||||
// error) on any miss — no key, URL-shaped id, request rejected, no photos, or
|
||||
// a failed media download — so the caller can fall back to Wikimedia.
|
||||
const fetchGooglePhoto = async (): Promise<{ filePath: string; attribution: string | null } | null> => {
|
||||
// URL-shaped placeIds aren't Google IDs — legacy DBs may store raw photo URLs in image_url
|
||||
if (!apiKey || /^https?:\/\//i.test(placeId)) return null;
|
||||
|
||||
// Fetch details to get the photo name
|
||||
const detailsRes = await googleFetch(`https://places.googleapis.com/v1/places/${placeId}`, `getPlacePhoto/details(${placeId})`, {
|
||||
headers: {
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'photos',
|
||||
},
|
||||
});
|
||||
const body = await detailsRes.text();
|
||||
if (!detailsRes.ok) {
|
||||
console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200));
|
||||
return null;
|
||||
}
|
||||
let details: GooglePlaceDetails & { error?: { message?: string } };
|
||||
try { details = body ? JSON.parse(body) : { photos: [] }; }
|
||||
catch { return null; }
|
||||
if (!details.photos?.length) return null;
|
||||
// Fetch details to get the photo name
|
||||
const detailsRes = await googleFetch(
|
||||
`https://places.googleapis.com/v1/places/${placeId}`,
|
||||
`getPlacePhoto/details(${placeId})`,
|
||||
{
|
||||
headers: {
|
||||
'X-Goog-Api-Key': apiKey,
|
||||
'X-Goog-FieldMask': 'photos',
|
||||
},
|
||||
},
|
||||
);
|
||||
const body = await detailsRes.text();
|
||||
if (!detailsRes.ok) {
|
||||
console.error('Google Places photo details error:', detailsRes.status, body.slice(0, 200));
|
||||
return null;
|
||||
}
|
||||
let details: GooglePlaceDetails & { error?: { message?: string } };
|
||||
try {
|
||||
details = body ? JSON.parse(body) : { photos: [] };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!details.photos?.length) return null;
|
||||
|
||||
const photo = details.photos[0];
|
||||
const photoName = photo.name;
|
||||
const attribution = photo.authorAttributions?.[0]?.displayName || null;
|
||||
const photo = details.photos[0];
|
||||
const photoName = photo.name;
|
||||
const attribution = photo.authorAttributions?.[0]?.displayName || null;
|
||||
|
||||
// Fetch actual image bytes
|
||||
const mediaRes = await googleFetch(
|
||||
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`,
|
||||
`getPlacePhoto/media(${placeId})`,
|
||||
{ headers: { 'X-Goog-Api-Key': apiKey } }
|
||||
);
|
||||
if (!mediaRes.ok) return null;
|
||||
// Fetch actual image bytes
|
||||
const mediaRes = await googleFetch(
|
||||
`https://places.googleapis.com/v1/${photoName}/media?maxHeightPx=400`,
|
||||
`getPlacePhoto/media(${placeId})`,
|
||||
{ headers: { 'X-Goog-Api-Key': apiKey } },
|
||||
);
|
||||
if (!mediaRes.ok) return null;
|
||||
|
||||
const bytes = Buffer.from(await mediaRes.arrayBuffer());
|
||||
if (!bytes.length) return null;
|
||||
const bytes = Buffer.from(await mediaRes.arrayBuffer());
|
||||
if (!bytes.length) return null;
|
||||
|
||||
const cached = await placePhotoCache.put(placeId, bytes, attribution);
|
||||
const cached = await placePhotoCache.put(placeId, bytes, attribution);
|
||||
|
||||
// Persist stable proxy URL to database
|
||||
try {
|
||||
db.prepare(
|
||||
'UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = \'\')'
|
||||
).run(cached.photoUrl, placeId);
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to persist photo URL to database:', dbErr);
|
||||
// Persist stable proxy URL to database
|
||||
try {
|
||||
db.prepare(
|
||||
"UPDATE places SET image_url = ?, updated_at = CURRENT_TIMESTAMP WHERE google_place_id = ? AND (image_url IS NULL OR image_url = '')",
|
||||
).run(cached.photoUrl, placeId);
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to persist photo URL to database:', dbErr);
|
||||
}
|
||||
|
||||
return { filePath: cached.filePath, attribution };
|
||||
};
|
||||
|
||||
// Prefer the Google photo (higher quality); if Google yields nothing, fall
|
||||
// back to the same coordinate-based Wikipedia/OSM lookup that right-click
|
||||
// places use. Coordinate-only ids skip Google entirely.
|
||||
if (!isCoordLookup) {
|
||||
const googlePhoto = await fetchGooglePhoto();
|
||||
if (googlePhoto) return googlePhoto;
|
||||
}
|
||||
|
||||
return { filePath: cached.filePath, attribution };
|
||||
};
|
||||
const fallback = await fetchWikimediaFallback();
|
||||
if (fallback) return fallback;
|
||||
|
||||
// Prefer the Google photo (higher quality); if Google yields nothing, fall
|
||||
// back to the same coordinate-based Wikipedia/OSM lookup that right-click
|
||||
// places use. Coordinate-only ids skip Google entirely.
|
||||
if (!isCoordLookup) {
|
||||
const googlePhoto = await fetchGooglePhoto();
|
||||
if (googlePhoto) return googlePhoto;
|
||||
}
|
||||
|
||||
const fallback = await fetchWikimediaFallback();
|
||||
if (fallback) return fallback;
|
||||
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
placePhotoCache.markError(placeId);
|
||||
return null;
|
||||
} finally {
|
||||
releasePhotoFetchSlot();
|
||||
}
|
||||
@@ -1028,16 +1136,24 @@ export async function getPlacePhoto(
|
||||
|
||||
// ── Reverse geocoding ────────────────────────────────────────────────────────
|
||||
|
||||
export async function reverseGeocode(lat: string, lng: string, lang?: string): Promise<{ name: string | null; address: string | null }> {
|
||||
export async function reverseGeocode(
|
||||
lat: string,
|
||||
lng: string,
|
||||
lang?: string,
|
||||
): Promise<{ name: string | null; address: string | null }> {
|
||||
const params = new URLSearchParams({
|
||||
lat, lon: lng, format: 'json', addressdetails: '1', zoom: '18',
|
||||
lat,
|
||||
lon: lng,
|
||||
format: 'json',
|
||||
addressdetails: '1',
|
||||
zoom: '18',
|
||||
'accept-language': toApiLang(lang),
|
||||
});
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?${params}`, {
|
||||
headers: { 'User-Agent': UA },
|
||||
});
|
||||
if (!response.ok) return { name: null, address: null };
|
||||
const data = await response.json() as { name?: string; display_name?: string; address?: Record<string, string> };
|
||||
const data = (await response.json()) as { name?: string; display_name?: string; address?: Record<string, string> };
|
||||
const addr = data.address || {};
|
||||
const name = data.name || addr.tourism || addr.amenity || addr.shop || addr.building || addr.road || null;
|
||||
return { name, address: data.display_name || null };
|
||||
@@ -1045,7 +1161,9 @@ export async function reverseGeocode(lat: string, lng: string, lang?: string): P
|
||||
|
||||
// ── Resolve Google Maps URL ──────────────────────────────────────────────────
|
||||
|
||||
export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number; lng: number; name: string | null; address: string | null; google_ftid: string | null }> {
|
||||
export async function resolveGoogleMapsUrl(
|
||||
url: string,
|
||||
): Promise<{ lat: number; lng: number; name: string | null; address: string | null; google_ftid: string | null }> {
|
||||
let resolvedUrl = url;
|
||||
|
||||
// Extract coordinates from a string (URL or page body). Google Maps encodes
|
||||
@@ -1119,9 +1237,13 @@ export async function resolveGoogleMapsUrl(url: string): Promise<{ lat: number;
|
||||
// Reverse geocode to get address
|
||||
const nominatimRes = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`,
|
||||
{ headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' }, signal: AbortSignal.timeout(8000) }
|
||||
{ headers: { 'User-Agent': 'TREK-Travel-Planner/1.0' }, signal: AbortSignal.timeout(8000) },
|
||||
);
|
||||
const nominatim = await nominatimRes.json() as { display_name?: string; name?: string; address?: Record<string, string> };
|
||||
const nominatim = (await nominatimRes.json()) as {
|
||||
display_name?: string;
|
||||
name?: string;
|
||||
address?: Record<string, string>;
|
||||
};
|
||||
|
||||
const name = placeName || nominatim.name || nominatim.address?.tourism || nominatim.address?.building || null;
|
||||
const address = nominatim.display_name || null;
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import nodemailer from 'nodemailer';
|
||||
import { db } from '../db/database';
|
||||
import { checkSsrf, createPinnedDispatcher } from '../utils/ssrfGuard';
|
||||
import { decrypt_api_key } from './apiKeyCrypto';
|
||||
import { logInfo, logDebug, logError } from './auditLog';
|
||||
import { checkSsrf, createPinnedDispatcher } from '../utils/ssrfGuard';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
import type { NotifEventType } from './notificationPreferencesService';
|
||||
import { EMAIL_I18N as I18N, EVENT_TEXTS, PASSWORD_RESET_I18N } from '@trek/shared/i18n/externalNotifications';
|
||||
import type { EmailStrings, EventText, PasswordResetStrings, NotificationEventKey } from '@trek/shared/i18n/externalNotifications';
|
||||
import type {
|
||||
EmailStrings,
|
||||
EventText,
|
||||
PasswordResetStrings,
|
||||
NotificationEventKey,
|
||||
} from '@trek/shared/i18n/externalNotifications';
|
||||
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
// Compile-time guard: shared NotificationEventKey and server NotifEventType must stay in sync.
|
||||
type _EvtFwd = NotifEventType extends NotificationEventKey ? true : never
|
||||
type _EvtBwd = NotificationEventKey extends NotifEventType ? true : never
|
||||
const _eventKeyDriftGuard: [_EvtFwd, _EvtBwd] = [true, true]
|
||||
type _EvtFwd = NotifEventType extends NotificationEventKey ? true : never;
|
||||
type _EvtBwd = NotificationEventKey extends NotifEventType ? true : never;
|
||||
const _eventKeyDriftGuard: [_EvtFwd, _EvtBwd] = [true, true];
|
||||
|
||||
interface SmtpConfig {
|
||||
host: string;
|
||||
@@ -38,7 +43,10 @@ function escapeHtml(str: string): string {
|
||||
// ── Settings helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function getAppSetting(key: string): string | null {
|
||||
return (db.prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as { value: string } | undefined)?.value || null;
|
||||
return (
|
||||
(db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined)?.value ||
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
function getSmtpConfig(): SmtpConfig | null {
|
||||
@@ -48,7 +56,14 @@ function getSmtpConfig(): SmtpConfig | null {
|
||||
const pass = process.env.SMTP_PASS || decrypt_api_key(getAppSetting('smtp_pass')) || '';
|
||||
const from = process.env.SMTP_FROM || getAppSetting('smtp_from');
|
||||
if (!host || !port || !from) return null;
|
||||
return { host, port: parseInt(port, 10), user: user || '', pass: pass || '', from, secure: parseInt(port, 10) === 465 };
|
||||
return {
|
||||
host,
|
||||
port: parseInt(port, 10),
|
||||
user: user || '',
|
||||
pass: pass || '',
|
||||
from,
|
||||
secure: parseInt(port, 10) === 465,
|
||||
};
|
||||
}
|
||||
|
||||
// Exported for use by notificationService
|
||||
@@ -57,8 +72,7 @@ export function getAppUrl(): string {
|
||||
try {
|
||||
const _ = new URL(process.env.APP_URL);
|
||||
return process.env.APP_URL.replace(/\/+$/, '');
|
||||
} catch (_ignored) {
|
||||
}
|
||||
} catch (_ignored) {}
|
||||
}
|
||||
const origins = process.env.ALLOWED_ORIGINS;
|
||||
if (origins) {
|
||||
@@ -67,8 +81,7 @@ export function getAppUrl(): string {
|
||||
try {
|
||||
const _ = new URL(first);
|
||||
return first.replace(/\/+$/, '');
|
||||
} catch (_ignored) {
|
||||
}
|
||||
} catch (_ignored) {}
|
||||
}
|
||||
}
|
||||
const port = Number(process.env.PORT) || 3001;
|
||||
@@ -99,15 +112,32 @@ export function isSmtpConfigured(): boolean {
|
||||
|
||||
export function getUserEmail(userId: number): string | null {
|
||||
// Defense-in-depth (#1362): a guest's synthetic email must never be emailed.
|
||||
return (db.prepare('SELECT email FROM users WHERE id = ? AND COALESCE(is_guest, 0) = 0').get(userId) as { email: string } | undefined)?.email || null;
|
||||
return (
|
||||
(
|
||||
db.prepare('SELECT email FROM users WHERE id = ? AND COALESCE(is_guest, 0) = 0').get(userId) as
|
||||
| { email: string }
|
||||
| undefined
|
||||
)?.email || null
|
||||
);
|
||||
}
|
||||
|
||||
export function getUserLanguage(userId: number): string {
|
||||
return (db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'language'").get(userId) as { value: string } | undefined)?.value || 'en';
|
||||
return (
|
||||
(
|
||||
db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'language'").get(userId) as
|
||||
| { value: string }
|
||||
| undefined
|
||||
)?.value || 'en'
|
||||
);
|
||||
}
|
||||
|
||||
export function getUserWebhookUrl(userId: number): string | null {
|
||||
const value = (db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(userId) as { value: string } | undefined)?.value || null;
|
||||
const value =
|
||||
(
|
||||
db.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(userId) as
|
||||
| { value: string }
|
||||
| undefined
|
||||
)?.value || null;
|
||||
return value ? decrypt_api_key(value) : null;
|
||||
}
|
||||
|
||||
@@ -130,10 +160,16 @@ export function getEventText(lang: string, event: NotifEventType, params: Record
|
||||
|
||||
// ── Email HTML builder ─────────────────────────────────────────────────────
|
||||
|
||||
export function buildEmailHtml(subject: string, body: string, lang: string, navigateTarget?: string, rawBody = false): string {
|
||||
export function buildEmailHtml(
|
||||
subject: string,
|
||||
body: string,
|
||||
lang: string,
|
||||
navigateTarget?: string,
|
||||
rawBody = false,
|
||||
): string {
|
||||
const s = I18N[lang] || I18N.en;
|
||||
const appUrl = getAppUrl();
|
||||
const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : (appUrl || ''));
|
||||
const ctaHref = escapeHtml(navigateTarget ? `${appUrl}${navigateTarget}` : appUrl || '');
|
||||
const safeSubject = escapeHtml(subject);
|
||||
const safeBody = rawBody ? body : escapeHtml(body);
|
||||
|
||||
@@ -157,13 +193,17 @@ export function buildEmailHtml(subject: string, body: string, lang: string, navi
|
||||
<p style="margin: 0; font-size: 14px; color: #4b5563; line-height: 1.7; white-space: pre-wrap;">${safeBody}</p>
|
||||
</td></tr>
|
||||
<!-- CTA -->
|
||||
${appUrl ? `<tr><td style="padding: 8px 32px 32px; text-align: center;">
|
||||
${
|
||||
appUrl
|
||||
? `<tr><td style="padding: 8px 32px 32px; text-align: center;">
|
||||
<a href="${ctaHref}" style="display: inline-block; padding: 12px 28px; background: #111827; color: #ffffff; font-size: 13px; font-weight: 600; text-decoration: none; border-radius: 10px; letter-spacing: 0.2px;">${s.openTrek}</a>
|
||||
</td></tr>` : ''}
|
||||
</td></tr>`
|
||||
: ''
|
||||
}
|
||||
<!-- Footer -->
|
||||
<tr><td style="padding: 20px 32px; background: #f9fafb; border-top: 1px solid #f3f4f6; text-align: center;">
|
||||
<p style="margin: 0 0 8px; font-size: 11px; color: #9ca3af; line-height: 1.5;">${s.footer}<br>${s.manage}</p>
|
||||
<p style="margin: 0; font-size: 10px; color: #d1d5db;">${s.madeWith} <span style="color: #ef4444;">♥</span> by Maurice · <a href="https://github.com/mauriceboe/TREK" style="color: #9ca3af; text-decoration: none;">GitHub</a></p>
|
||||
<p style="margin: 0; font-size: 10px; color: #d1d5db;">${s.madeWith} <span style="color: #ef4444;">♥</span> by Maurice · <a href="https://github.com/liketrek/TREK" style="color: #9ca3af; text-decoration: none;">GitHub</a></p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
@@ -178,7 +218,13 @@ export function buildEmailHtml(subject: string, body: string, lang: string, navi
|
||||
|
||||
// PASSWORD_RESET_I18N imported from @trek/shared/i18n/externalNotifications
|
||||
|
||||
function buildPasswordResetHtml(subject: string, strings: PasswordResetStrings, recipient: string, resetUrl: string, lang: string): string {
|
||||
function buildPasswordResetHtml(
|
||||
subject: string,
|
||||
strings: PasswordResetStrings,
|
||||
recipient: string,
|
||||
resetUrl: string,
|
||||
lang: string,
|
||||
): string {
|
||||
const safeGreeting = escapeHtml(`${strings.greeting}, ${recipient}`);
|
||||
const safeBody = escapeHtml(strings.body);
|
||||
const safeExpiry = escapeHtml(strings.expiry);
|
||||
@@ -217,14 +263,14 @@ export async function sendPasswordResetEmail(
|
||||
// No SMTP configured — log the link in a visually distinct block so
|
||||
// the admin can relay it. Never log the associated user id/email
|
||||
// content at a lower level, only what's needed.
|
||||
|
||||
|
||||
console.log(
|
||||
`\n===== PASSWORD RESET LINK =====\n` +
|
||||
`to: ${to}\n` +
|
||||
`url: ${resetUrl}\n` +
|
||||
`expires: 60 minutes\n` +
|
||||
`(SMTP is not configured — deliver this link to the user manually.)\n` +
|
||||
`================================\n`,
|
||||
`to: ${to}\n` +
|
||||
`url: ${resetUrl}\n` +
|
||||
`expires: 60 minutes\n` +
|
||||
`(SMTP is not configured — deliver this link to the user manually.)\n` +
|
||||
`================================\n`,
|
||||
);
|
||||
logInfo(`Password reset link issued (no SMTP) for=${to}`);
|
||||
return { delivered: 'log' };
|
||||
@@ -254,7 +300,13 @@ export async function sendPasswordResetEmail(
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendEmail(to: string, subject: string, body: string, userId?: number, navigateTarget?: string): Promise<boolean> {
|
||||
export async function sendEmail(
|
||||
to: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
userId?: number,
|
||||
navigateTarget?: string,
|
||||
): Promise<boolean> {
|
||||
const config = getSmtpConfig();
|
||||
if (!config) return false;
|
||||
|
||||
@@ -286,20 +338,25 @@ export async function sendEmail(to: string, subject: string, body: string, userI
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWebhookBody(url: string, payload: { event: string; title: string; body: string; tripName?: string; link?: string }): string {
|
||||
export function buildWebhookBody(
|
||||
url: string,
|
||||
payload: { event: string; title: string; body: string; tripName?: string; link?: string },
|
||||
): string {
|
||||
const isDiscord = /discord(?:app)?\.com\/api\/webhooks\//.test(url);
|
||||
const isSlack = /hooks\.slack\.com\//.test(url);
|
||||
|
||||
if (isDiscord) {
|
||||
return JSON.stringify({
|
||||
embeds: [{
|
||||
title: `📍 ${payload.title}`,
|
||||
description: payload.body,
|
||||
url: payload.link,
|
||||
color: 0x3b82f6,
|
||||
footer: { text: payload.tripName ? `Trip: ${payload.tripName}` : 'TREK' },
|
||||
timestamp: new Date().toISOString(),
|
||||
}],
|
||||
embeds: [
|
||||
{
|
||||
title: `📍 ${payload.title}`,
|
||||
description: payload.body,
|
||||
url: payload.link,
|
||||
color: 0x3b82f6,
|
||||
footer: { text: payload.tripName ? `Trip: ${payload.tripName}` : 'TREK' },
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -314,7 +371,10 @@ export function buildWebhookBody(url: string, payload: { event: string; title: s
|
||||
return JSON.stringify({ ...payload, timestamp: new Date().toISOString(), source: 'TREK' });
|
||||
}
|
||||
|
||||
export async function sendWebhook(url: string, payload: { event: string; title: string; body: string; tripName?: string; link?: string }): Promise<boolean> {
|
||||
export async function sendWebhook(
|
||||
url: string,
|
||||
payload: { event: string; title: string; body: string; tripName?: string; link?: string },
|
||||
): Promise<boolean> {
|
||||
if (!url) return false;
|
||||
|
||||
const ssrf = await checkSsrf(url);
|
||||
@@ -373,7 +433,11 @@ export async function testSmtp(to: string): Promise<{ success: boolean; error?:
|
||||
|
||||
export async function testWebhook(url: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const sent = await sendWebhook(url, { event: 'test', title: 'Test Notification', body: 'This is a test webhook from TREK. If you received this, your webhook configuration is working correctly.' });
|
||||
const sent = await sendWebhook(url, {
|
||||
event: 'test',
|
||||
title: 'Test Notification',
|
||||
body: 'This is a test webhook from TREK. If you received this, your webhook configuration is working correctly.',
|
||||
});
|
||||
return sent ? { success: true } : { success: false, error: 'Failed to send webhook' };
|
||||
} catch (err) {
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
@@ -390,22 +454,22 @@ export interface NtfyConfig {
|
||||
|
||||
/** Priority and tags mapped to each notification event type. */
|
||||
const NTFY_EVENT_META: Partial<Record<NotifEventType, { priority: 1 | 2 | 3 | 4 | 5; tags: string[] }>> = {
|
||||
trip_invite: { priority: 4, tags: ['loudspeaker'] },
|
||||
booking_change: { priority: 3, tags: ['calendar'] },
|
||||
trip_reminder: { priority: 4, tags: ['bell', 'alarm_clock'] },
|
||||
vacay_invite: { priority: 4, tags: ['palm_tree'] },
|
||||
photos_shared: { priority: 3, tags: ['camera'] },
|
||||
collab_message: { priority: 3, tags: ['speech_balloon'] },
|
||||
packing_tagged: { priority: 3, tags: ['luggage'] },
|
||||
version_available: { priority: 4, tags: ['package'] },
|
||||
trip_invite: { priority: 4, tags: ['loudspeaker'] },
|
||||
booking_change: { priority: 3, tags: ['calendar'] },
|
||||
trip_reminder: { priority: 4, tags: ['bell', 'alarm_clock'] },
|
||||
vacay_invite: { priority: 4, tags: ['palm_tree'] },
|
||||
photos_shared: { priority: 3, tags: ['camera'] },
|
||||
collab_message: { priority: 3, tags: ['speech_balloon'] },
|
||||
packing_tagged: { priority: 3, tags: ['luggage'] },
|
||||
version_available: { priority: 4, tags: ['package'] },
|
||||
synology_session_cleared: { priority: 3, tags: ['warning'] },
|
||||
};
|
||||
const NTFY_DEFAULT_META = { priority: 3 as const, tags: [] as string[] };
|
||||
|
||||
export function getUserNtfyConfig(userId: number): NtfyConfig | null {
|
||||
const rows = db.prepare(
|
||||
"SELECT key, value FROM settings WHERE user_id = ? AND key IN ('ntfy_topic', 'ntfy_server', 'ntfy_token')"
|
||||
).all(userId) as { key: string; value: string }[];
|
||||
const rows = db
|
||||
.prepare("SELECT key, value FROM settings WHERE user_id = ? AND key IN ('ntfy_topic', 'ntfy_server', 'ntfy_token')")
|
||||
.all(userId) as { key: string; value: string }[];
|
||||
if (rows.length === 0) return null;
|
||||
const map: Record<string, string> = {};
|
||||
for (const r of rows) map[r.key] = r.value;
|
||||
@@ -440,7 +504,7 @@ export function resolveNtfyUrl(adminCfg: NtfyConfig, userCfg: NtfyConfig | null)
|
||||
|
||||
function encodeHeaderValue(value: string): string {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (value.charCodeAt(i) > 0xFF) {
|
||||
if (value.charCodeAt(i) > 0xff) {
|
||||
return `=?UTF-8?B?${Buffer.from(value, 'utf8').toString('base64')}?=`;
|
||||
}
|
||||
}
|
||||
@@ -464,8 +528,8 @@ export async function sendNtfy(
|
||||
|
||||
// ntfy header-based API: POST to topic URL, body = plain text message, metadata in headers
|
||||
const headers: Record<string, string> = {
|
||||
'Title': encodeHeaderValue(payload.title),
|
||||
'Priority': String(meta.priority),
|
||||
Title: encodeHeaderValue(payload.title),
|
||||
Priority: String(meta.priority),
|
||||
};
|
||||
if (meta.tags.length > 0) headers['Tags'] = meta.tags.join(',');
|
||||
if (payload.link) headers['Click'] = encodeHeaderValue(payload.link);
|
||||
@@ -495,7 +559,11 @@ export async function sendNtfy(
|
||||
}
|
||||
}
|
||||
|
||||
export async function testNtfy(cfg: { topic: string; server?: string | null; token?: string | null }): Promise<{ success: boolean; error?: string }> {
|
||||
export async function testNtfy(cfg: {
|
||||
topic: string;
|
||||
server?: string | null;
|
||||
token?: string | null;
|
||||
}): Promise<{ success: boolean; error?: string }> {
|
||||
const adminCfg = getAdminNtfyConfig();
|
||||
const url = resolveNtfyUrl(adminCfg, { topic: cfg.topic, server: cfg.server ?? null, token: cfg.token ?? null });
|
||||
if (!url) return { success: false, error: 'Could not resolve ntfy URL — missing topic' };
|
||||
@@ -510,4 +578,3 @@ export async function testNtfy(cfg: { topic: string; server?: string | null; tok
|
||||
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,12 @@
|
||||
* Unit tests for checkAndNotifyVersion() in adminService.
|
||||
* Covers VNOTIF-001 to VNOTIF-007.
|
||||
*/
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { checkAndNotifyVersion, __clearVersionCacheForTests } from '../../../src/services/adminService';
|
||||
import { createAdmin } from '../../helpers/factories';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
@@ -30,18 +36,15 @@ vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
|
||||
// Mock MCP to avoid session side-effects
|
||||
vi.mock('../../../src/mcp', () => ({ revokeUserSessions: vi.fn() }));
|
||||
|
||||
import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createAdmin } from '../../helpers/factories';
|
||||
import { checkAndNotifyVersion, __clearVersionCacheForTests } from '../../../src/services/adminService';
|
||||
|
||||
// Helper: mock the GitHub releases/latest endpoint
|
||||
function mockGitHubLatest(tagName: string, ok = true): void {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||||
ok,
|
||||
json: async () => ({ tag_name: tagName, html_url: `https://github.com/mauriceboe/TREK/releases/tag/${tagName}` }),
|
||||
}));
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok,
|
||||
json: async () => ({ tag_name: tagName, html_url: `https://github.com/liketrek/TREK/releases/tag/${tagName}` }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function mockGitHubFetchFailure(): void {
|
||||
@@ -49,7 +52,11 @@ function mockGitHubFetchFailure(): void {
|
||||
}
|
||||
|
||||
function getLastNotifiedVersion(): string | undefined {
|
||||
return (testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as { value: string } | undefined)?.value;
|
||||
return (
|
||||
testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as
|
||||
| { value: string }
|
||||
| undefined
|
||||
)?.value;
|
||||
}
|
||||
|
||||
function getNotificationCount(): number {
|
||||
@@ -96,9 +103,13 @@ describe('checkAndNotifyVersion', () => {
|
||||
|
||||
await checkAndNotifyVersion();
|
||||
|
||||
const notifications = testDb.prepare('SELECT * FROM notifications ORDER BY id').all() as Array<{ recipient_id: number; type: string; scope: string }>;
|
||||
const notifications = testDb.prepare('SELECT * FROM notifications ORDER BY id').all() as Array<{
|
||||
recipient_id: number;
|
||||
type: string;
|
||||
scope: string;
|
||||
}>;
|
||||
expect(notifications.length).toBe(2);
|
||||
const recipientIds = notifications.map(n => n.recipient_id);
|
||||
const recipientIds = notifications.map((n) => n.recipient_id);
|
||||
expect(recipientIds).toContain(admin1.id);
|
||||
expect(recipientIds).toContain(admin2.id);
|
||||
expect(notifications[0].type).toBe('navigate');
|
||||
@@ -131,7 +142,9 @@ describe('checkAndNotifyVersion', () => {
|
||||
it('VNOTIF-005 — creates new notification when last_notified_version is an older version', async () => {
|
||||
createAdmin(testDb);
|
||||
// Simulate having been notified about an older version
|
||||
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('last_notified_version', '98.0.0');
|
||||
testDb
|
||||
.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
|
||||
.run('last_notified_version', '98.0.0');
|
||||
mockGitHubLatest('v99.3.0');
|
||||
|
||||
await checkAndNotifyVersion();
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@
|
||||
</Branch>
|
||||
<Network>bridge</Network>
|
||||
<Privileged>false</Privileged>
|
||||
<Support>https://github.com/mauriceboe/TREK/issues</Support>
|
||||
<Project>https://github.com/mauriceboe/TREK</Project>
|
||||
<Support>https://github.com/liketrek/TREK/issues</Support>
|
||||
<Project>https://github.com/liketrek/TREK</Project>
|
||||
<Overview>TREK is a self-hosted, real-time collaborative travel planner with interactive maps, budgets, bookings, packing lists, file management, and more. Plan trips together with your group — changes sync instantly across all connected users. Includes OIDC/SSO support, dark mode, PWA, and a modular addon system (Vacay, Atlas, Collab, Budget, Packing).</Overview>
|
||||
<Category>Productivity: Tools:</Category>
|
||||
<WebUI>http://[IP]:[PORT:3000]</WebUI>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
## 1. Fork & Clone the Repository
|
||||
|
||||
Go to the [TREK repository](https://github.com/mauriceboe/TREK) and click **Fork** to create your own copy.
|
||||
Go to the [TREK repository](https://github.com/liketrek/TREK) and click **Fork** to create your own copy.
|
||||
|
||||
Then clone your fork locally:
|
||||
|
||||
|
||||
@@ -235,7 +235,7 @@ When left at the default, using the transit feature makes the TREK **server** se
|
||||
|
||||
TREK can search [Unsplash](https://unsplash.com/) for **trip cover images** and **place images**. By default the server queries Unsplash's public web endpoint **without an API key**, so no configuration is needed on most installs.
|
||||
|
||||
Some hosting environments — commonly VPS and datacenter IP ranges (and many Kubernetes clusters) — are **blocked or rate-limited** by that unauthenticated endpoint, which surfaces in the UI as **"Unsplash search unavailable"**. Configuring a free Unsplash Access Key switches the server to Unsplash's official, authenticated API (`api.unsplash.com`), which is not subject to that block. See [issue #1449](https://github.com/mauriceboe/TREK/issues/1449).
|
||||
Some hosting environments — commonly VPS and datacenter IP ranges (and many Kubernetes clusters) — are **blocked or rate-limited** by that unauthenticated endpoint, which surfaces in the UI as **"Unsplash search unavailable"**. Configuring a free Unsplash Access Key switches the server to Unsplash's official, authenticated API (`api.unsplash.com`), which is not subject to that block. See [issue #1449](https://github.com/liketrek/TREK/issues/1449).
|
||||
|
||||
| Variable | Description | Default |
|
||||
|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------|
|
||||
|
||||
@@ -4,7 +4,7 @@ Production-ready setup using Docker Compose with security hardening enabled.
|
||||
|
||||
## Compose File
|
||||
|
||||
See https://github.com/mauriceboe/TREK/blob/main/docker-compose.yml
|
||||
See https://github.com/liketrek/TREK/blob/main/docker-compose.yml
|
||||
|
||||
## Security Hardening Explained
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ helm upgrade trek trek/trek
|
||||
|
||||
## Full Values Reference
|
||||
|
||||
See the [`charts/README.md`](https://github.com/mauriceboe/TREK/blob/main/charts/README.md) for all available values.
|
||||
See the [`charts/README.md`](https://github.com/liketrek/TREK/blob/main/charts/README.md) for all available values.
|
||||
|
||||
## Next Steps
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Portainer must be installed and connected to your Docker environment. Use **Stac
|
||||
|
||||
1. In Portainer, go to **Stacks → Add stack**.
|
||||
2. Give the stack a name (e.g. `trek`).
|
||||
3. Select **Web editor** and paste the compose file from [docker-compose.yml](https://github.com/mauriceboe/TREK/blob/main/docker-compose.yml).
|
||||
3. Select **Web editor** and paste the compose file from [docker-compose.yml](https://github.com/liketrek/TREK/blob/main/docker-compose.yml).
|
||||
|
||||

|
||||
|
||||
@@ -26,7 +26,7 @@ Portainer must be installed and connected to your Docker environment. Use **Stac
|
||||
|
||||
## Compose Content
|
||||
|
||||
See https://github.com/mauriceboe/TREK/blob/main/docker-compose.yml
|
||||
See https://github.com/liketrek/TREK/blob/main/docker-compose.yml
|
||||
|
||||
Set at minimum `ENCRYPTION_KEY`, `TZ`, and `APP_URL` in the **Environment variables** section of the stack editor. Generate an encryption key with:
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ for the permission catalogue see [[Plugin Permissions|Plugin-Permissions]].
|
||||
> loudly; it never silently escalates.
|
||||
|
||||
The complete, runnable version of these recipes is the
|
||||
[`trip-doctor`](https://github.com/mauriceboe/TREK/tree/main/plugin-sdk/examples/trip-doctor)
|
||||
[`trip-doctor`](https://github.com/liketrek/TREK/tree/main/plugin-sdk/examples/trip-doctor)
|
||||
example plugin.
|
||||
|
||||
---
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Publishing a Plugin
|
||||
|
||||
Plugins are distributed from a static registry — the
|
||||
[TREK-Plugins](https://github.com/mauriceboe/TREK-Plugins) GitHub repo. There is
|
||||
[TREK-Plugins](https://github.com/liketrek/TREK-Plugins) GitHub repo. There is
|
||||
no upload server and no account: you host the code in your own public GitHub repo,
|
||||
attach a built `plugin.zip` to a release, and list it with a pull request.
|
||||
|
||||
@@ -217,7 +217,7 @@ The fast path — `submit` does the whole fork/branch/commit/PR dance for you:
|
||||
npx trek-plugin-sdk submit --repo you/trek-plugin-flight-tracker --tag v1.0.0
|
||||
```
|
||||
|
||||
It forks [TREK-Plugins](https://github.com/mauriceboe/TREK-Plugins) (once),
|
||||
It forks [TREK-Plugins](https://github.com/liketrek/TREK-Plugins) (once),
|
||||
branches off the current `main`, writes (or, for an update, merges into)
|
||||
`registry/plugins/<id>.json`, pushes, and opens the PR — printing its URL. Add
|
||||
`--draft` for a draft PR, `--registry <owner/name>` for a mirror. (Requires `gh`,
|
||||
@@ -227,8 +227,8 @@ authenticated.)
|
||||
`registry/plugins/<id>.json`, and open a PR back to `main`. Add **only** that
|
||||
file — `dist/` is generated on merge, and CI rejects manual edits to it.
|
||||
|
||||
The entry follows [`schema/plugin-entry.schema.json`](https://github.com/mauriceboe/TREK-Plugins/blob/main/schema/plugin-entry.schema.json);
|
||||
[`schema/example-entry.json`](https://github.com/mauriceboe/TREK-Plugins/blob/main/schema/example-entry.json)
|
||||
The entry follows [`schema/plugin-entry.schema.json`](https://github.com/liketrek/TREK-Plugins/blob/main/schema/plugin-entry.schema.json);
|
||||
[`schema/example-entry.json`](https://github.com/liketrek/TREK-Plugins/blob/main/schema/example-entry.json)
|
||||
is the canonical shape. `size` is **required** (a common omission), as are
|
||||
`commitSha`, `downloadUrl`, `sha256`, `trek`, `apiVersion`, and
|
||||
`nativeModules: false` on every version — all of which `trek-plugin entry` fills
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
[AGPL-3.0 License](https://github.com/mauriceboe/TREK/blob/main/LICENSE) · [GitHub](https://github.com/mauriceboe/TREK) · [Discord](https://discord.gg/NhZBDSd4qW) · [Security Hardening](Security-Hardening)
|
||||
[AGPL-3.0 License](https://github.com/liketrek/TREK/blob/main/LICENSE) · [GitHub](https://github.com/liketrek/TREK) · [Discord](https://discord.gg/NhZBDSd4qW) · [Security Hardening](Security-Hardening)
|
||||
|
||||
Reference in New Issue
Block a user