mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-25 00:13:00 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9fb0fc475 | |||
| 5596355141 | |||
| 9c83386655 | |||
| 98961dadd5 | |||
| 7ada99acfc | |||
| 2ac333f842 | |||
| 270a13ff60 | |||
| d800e64f93 | |||
| 390135f3bc | |||
| cad7d54e83 | |||
| 2df677d065 | |||
| 03493d4f79 | |||
| eae74c5605 |
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 490,
|
||||
"@typescript-eslint/no-non-null-assertion": 1929,
|
||||
"@typescript-eslint/no-non-null-assertion": 1927,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
|
||||
@@ -661,9 +661,24 @@
|
||||
"desktop": ["src/components/Roadtrip/RoadtripStayModal.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Demo account locks",
|
||||
"hooks": ["src/components/Demo/useDemoLock.ts"],
|
||||
"desktop": [
|
||||
"src/components/Settings/AccountDemoLock.tsx",
|
||||
"src/components/Settings/DisplaySettingsTab.tsx",
|
||||
"src/components/Settings/IntegrationsTab.tsx",
|
||||
"src/components/Settings/ai/AiConnectionSection.tsx"
|
||||
],
|
||||
"mobile": [
|
||||
"src/mobile/screens/settings/MAiConnectionSection.tsx",
|
||||
"src/mobile/screens/settings/MSettingsGeneral.tsx",
|
||||
"src/mobile/screens/settings/MSettingsMcp.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Account",
|
||||
"hooks": ["src/components/Settings/useAccountSettings.ts"],
|
||||
"hooks": ["src/components/Settings/AccountDemoLock.tsx", "src/components/Settings/useAccountSettings.ts"],
|
||||
"desktop": ["src/components/Settings/AccountTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MSettingsAccount.tsx"]
|
||||
},
|
||||
|
||||
@@ -408,7 +408,6 @@
|
||||
"src/components/Journey/usePhotoZoom.ts",
|
||||
"src/components/Layout/BottomNav.test.tsx",
|
||||
"src/components/Layout/BottomNav.tsx",
|
||||
"src/components/Layout/DemoBanner.tsx",
|
||||
"src/components/Layout/InAppNotificationBell.test.tsx",
|
||||
"src/components/Layout/InAppNotificationBell.tsx",
|
||||
"src/components/Layout/MobileTopBar.test.tsx",
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
"components/Journey/journeyMapPopup.ts": 17,
|
||||
"components/Journey/moodConfig.ts": 5,
|
||||
"components/Layout/BottomNav.tsx": 3,
|
||||
"components/Layout/DemoBanner.tsx": 25,
|
||||
"components/Layout/InAppNotificationBell.tsx": 3,
|
||||
"components/Layout/MobileTopBar.tsx": 51,
|
||||
"components/Layout/Navbar.tsx": 21,
|
||||
@@ -207,7 +206,7 @@
|
||||
"mobile/screens/settings/MSetPickerSheet.tsx": 2,
|
||||
"mobile/screens/settings/MSettings.tsx": 2,
|
||||
"mobile/screens/settings/MSettingsAbout.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsAccount.tsx": 21,
|
||||
"mobile/screens/settings/MSettingsAccount.tsx": 20,
|
||||
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
|
||||
"mobile/screens/settings/MSettingsMap.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsMcp.tsx": 30,
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import {
|
||||
PHOTO_CREDITS,
|
||||
PUBLIC_DOMAIN,
|
||||
commonsPage,
|
||||
creditTitle,
|
||||
licenseDeed,
|
||||
type PhotoCredit,
|
||||
} from './demoCreditsModel';
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
const LINK = 'underline decoration-edge underline-offset-2 hover:text-content';
|
||||
|
||||
/** One photo: its title linking to its page on Commons, who took it, and its license linking to the deed. */
|
||||
function CreditRow({ credit, t }: { credit: PhotoCredit; t: Translate }) {
|
||||
const deed = licenseDeed(credit.license);
|
||||
const license = credit.license === PUBLIC_DOMAIN ? t('demo.credits.publicDomain') : credit.license;
|
||||
return (
|
||||
<li className="flex flex-col gap-0.5 py-2.5">
|
||||
<a
|
||||
href={commonsPage(credit.file)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`${LINK} self-start break-words font-medium text-content`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{creditTitle(credit.file)}
|
||||
</a>
|
||||
<span className="flex flex-wrap gap-x-3 gap-y-0.5 text-content-muted" style={fs(12, 'body')}>
|
||||
<span>{t('demo.credits.by', { author: credit.author })}</span>
|
||||
{deed ? (
|
||||
<a href={deed} target="_blank" rel="noopener noreferrer" className={LINK}>
|
||||
{license}
|
||||
</a>
|
||||
) : (
|
||||
<span>{license}</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The credits for the demo's photos, the welcome dialog's second view. Its own
|
||||
* chunk, loaded when a visitor opens it: only a demo instance shows the
|
||||
* welcome, and few visitors open its credits.
|
||||
*/
|
||||
export default function DemoCredits() {
|
||||
const { t } = useTranslation();
|
||||
const intro = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
// The dialog's body keeps the welcome's scroll position when the credits
|
||||
// take its place; they start at their top.
|
||||
useEffect(() => {
|
||||
intro.current?.scrollIntoView({ block: 'nearest' });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p ref={intro} className="m-0 scroll-mt-5 leading-snug text-content-secondary" style={fs(12.5, 'body')}>
|
||||
{t('demo.credits.intro')}
|
||||
</p>
|
||||
<ul
|
||||
aria-label={t('demo.credits.title')}
|
||||
className="m-0 list-none divide-y divide-edge-faint rounded-[16px] border border-edge-faint bg-surface-card px-3.5"
|
||||
>
|
||||
{PHOTO_CREDITS.map((credit) => (
|
||||
<CreditRow key={credit.file} credit={credit} t={t} />
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import DemoLock from './DemoLock';
|
||||
|
||||
describe('DemoLock', () => {
|
||||
it('FE-COMP-DEMOLOCK-001: unlocked, the section is the section and nothing more', () => {
|
||||
const { container } = render(
|
||||
<DemoLock locked={false} reason="Not here.">
|
||||
<button type="button">Create key</button>
|
||||
</DemoLock>
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Create key' })).toBeEnabled();
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('[data-demo-lock]')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOLOCK-002: locked, the section stays visible but out of reach, under the badge and the reason', () => {
|
||||
const { container } = render(
|
||||
<DemoLock locked reason="API keys cannot be created in the demo.">
|
||||
<button type="button">Create key</button>
|
||||
</DemoLock>
|
||||
);
|
||||
const note = screen.getByRole('note');
|
||||
expect(note).toHaveTextContent('Demo mode');
|
||||
expect(note).toHaveTextContent('API keys cannot be created in the demo.');
|
||||
expect(screen.queryByRole('button', { name: 'Create key' })).not.toBeInTheDocument();
|
||||
const veiled = container.querySelector('[data-demo-lock] > [inert]');
|
||||
expect(veiled).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(veiled).toContainElement(screen.getByRole('button', { hidden: true, name: 'Create key' }));
|
||||
expect(note.getAttribute('style')).toContain('repeating-linear-gradient');
|
||||
expect(note.getAttribute('style')).toContain('var(--text-primary)');
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOLOCK-003: a single row puts the note in its middle, a section at its top', () => {
|
||||
const { rerender } = render(
|
||||
<DemoLock locked reason="r" size="row">
|
||||
<span>row</span>
|
||||
</DemoLock>
|
||||
);
|
||||
expect(screen.getByRole('note').className).toContain('items-center');
|
||||
rerender(
|
||||
<DemoLock locked reason="r">
|
||||
<span>section</span>
|
||||
</DemoLock>
|
||||
);
|
||||
expect(screen.getByRole('note').className).toContain('items-start');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Lock } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
|
||||
interface DemoLockProps {
|
||||
/** From useDemoAccountLock: false renders the children untouched. */
|
||||
locked: boolean;
|
||||
/** One line saying what the demo does not allow here. */
|
||||
reason: string;
|
||||
children: ReactNode;
|
||||
/** `row` for a single settings row: the note sits in the middle instead of at the top. */
|
||||
size?: 'section' | 'row';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Thin diagonal stripes in the theme's own ink, so they read in light and dark alike. */
|
||||
const STRIPES =
|
||||
'repeating-linear-gradient(135deg, color-mix(in srgb, var(--text-primary) 8%, transparent) 0 9px, transparent 9px 18px)';
|
||||
|
||||
/**
|
||||
* A section the shared demo account may look at but not change: dimmed,
|
||||
* out of reach for pointer and keyboard, under a striped veil that carries a
|
||||
* small "Demo mode" badge and the reason. Used on the desktop settings and on
|
||||
* the phone screens alike.
|
||||
*/
|
||||
export default function DemoLock({ locked, reason, children, size = 'section', className = '' }: DemoLockProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!locked) return <>{children}</>;
|
||||
return (
|
||||
<div className={`relative ${className}`} data-demo-lock="">
|
||||
<div inert aria-hidden="true" className="pointer-events-none select-none opacity-40 saturate-50">
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
role="note"
|
||||
className={`absolute inset-0 flex justify-center rounded-2xl border border-dashed border-edge-secondary ${
|
||||
size === 'row' ? 'items-center p-1' : 'items-start p-3'
|
||||
}`}
|
||||
style={{ background: STRIPES }}
|
||||
>
|
||||
<div className="sticky top-3 flex max-w-full flex-wrap items-center justify-center gap-x-2.5 gap-y-1 rounded-2xl bg-surface-card px-3 py-2 text-center shadow-md ring-1 ring-edge-faint">
|
||||
<span
|
||||
className="inline-flex flex-none items-center gap-1 rounded-full bg-accent px-2 py-0.5 font-semibold uppercase tracking-[.06em] text-accent-text"
|
||||
style={fs(10)}
|
||||
>
|
||||
<Lock size={11} strokeWidth={2.4} aria-hidden />
|
||||
{t('demo.lock.badge')}
|
||||
</span>
|
||||
<span className="min-w-0 text-content-secondary" style={fs(12.5, 'body')}>
|
||||
{reason}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { act, fireEvent, within } 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 { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import DemoWelcome from './DemoWelcome';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { serverTimezone: 'UTC' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('DemoWelcome', () => {
|
||||
it('FE-COMP-DEMOWELCOME-001: opens as a dialog saying what TREK is', () => {
|
||||
render(<DemoWelcome />);
|
||||
const dialog = screen.getByRole('dialog', { name: 'Live demo' });
|
||||
expect(dialog).toHaveTextContent('Welcome to TREK');
|
||||
expect(dialog).toHaveTextContent('The travel planner you share');
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOWELCOME-002: says the demo resets every hour and what it cannot do', () => {
|
||||
render(<DemoWelcome />);
|
||||
expect(screen.getByText('Everything resets every hour')).toBeInTheDocument();
|
||||
for (const line of [
|
||||
'Uploading photos, documents and covers',
|
||||
'Changing the account, its password or 2FA',
|
||||
'Creating API keys',
|
||||
'Connecting MCP clients',
|
||||
'Connecting your own AI model',
|
||||
])
|
||||
expect(screen.getByText(line)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOWELCOME-003: shows the highlights to try and what the full version adds', () => {
|
||||
render(<DemoWelcome />);
|
||||
for (const title of [
|
||||
'Trip planner',
|
||||
'Tours and hikes',
|
||||
'Photo books in TREK Studio',
|
||||
'Plan together live',
|
||||
'Journey travel journal',
|
||||
'Atlas world map',
|
||||
'Vacay holiday planner',
|
||||
'Collections',
|
||||
'Works offline',
|
||||
])
|
||||
expect(screen.getByText(title)).toBeInTheDocument();
|
||||
for (const line of ['Sign in with OIDC and SSO', 'Automatic backups', 'Plugins for new features'])
|
||||
expect(screen.getByText(line)).toBeInTheDocument();
|
||||
expect(screen.getByText('The AI assistant on your own model')).toBeInTheDocument();
|
||||
expect(screen.getByText('An admin panel for users, addons and permissions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOWELCOME-004: links to the repository in a new tab', () => {
|
||||
render(<DemoWelcome />);
|
||||
const link = screen.getByRole('link', { name: 'Source code on GitHub' });
|
||||
expect(link).toHaveAttribute('href', 'https://github.com/liketrek/TREK');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOWELCOME-005: the countdown runs live in the server zone', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: false });
|
||||
// On a whole second, so every tick of the interval lands on one too.
|
||||
vi.setSystemTime(new Date(Date.UTC(2026, 9, 24, 12, 17, 53)));
|
||||
render(<DemoWelcome />);
|
||||
expect(screen.getByTestId('demo-countdown')).toHaveTextContent('42:07');
|
||||
|
||||
// Advancing the fake timers moves the fake clock with them: the fifth tick reads 12:17:58.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(screen.getByTestId('demo-countdown')).toHaveTextContent('42:02');
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOWELCOME-006: the button closes it', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DemoWelcome />);
|
||||
await user.click(screen.getByRole('button', { name: 'Start exploring' }));
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOWELCOME-007: Escape closes it too', () => {
|
||||
render(<DemoWelcome />);
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOWELCOME-008: the photo credits open in its place and the back button returns', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DemoWelcome />);
|
||||
await user.click(screen.getByRole('button', { name: 'Photo credits' }));
|
||||
|
||||
const list = await screen.findByRole('list', { name: 'Photo credits' }, { timeout: 5000 });
|
||||
expect(within(list).getAllByRole('listitem')).toHaveLength(228);
|
||||
expect(screen.getByText(/come from Wikimedia Commons/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Photo credits' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Everything resets every hour')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog', { name: 'Live demo' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Back' })).toHaveFocus();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Back' }));
|
||||
expect(screen.getByText('Everything resets every hour')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('list', { name: 'Photo credits' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Photo credits' })).toHaveFocus();
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOWELCOME-009: a photo names its title, author and license, each linking to its source', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DemoWelcome />);
|
||||
await user.click(screen.getByRole('button', { name: 'Photo credits' }));
|
||||
|
||||
const title = await screen.findByRole('link', { name: 'Lagoa Rasa (Sete Cidades) 2' }, { timeout: 5000 });
|
||||
expect(title).toHaveAttribute('href', 'https://commons.wikimedia.org/wiki/File:Lagoa_Rasa_(Sete_Cidades)_2.jpg');
|
||||
expect(title).toHaveAttribute('target', '_blank');
|
||||
expect(title).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
const row = within(title.closest('li') as HTMLElement);
|
||||
expect(row.getByText('by Walmaul')).toBeInTheDocument();
|
||||
const license = row.getByRole('link', { name: 'CC BY-SA 4.0' });
|
||||
expect(license).toHaveAttribute('href', 'https://creativecommons.org/licenses/by-sa/4.0/');
|
||||
expect(license).toHaveAttribute('target', '_blank');
|
||||
expect(license).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOWELCOME-010: a public domain photo names it without a deed, and a cleaned author reads as a name', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DemoWelcome />);
|
||||
await user.click(screen.getByRole('button', { name: 'Photo credits' }));
|
||||
|
||||
const abiskoTitle = await screen.findByRole('link', { name: 'AbiskoNationalpark01' }, { timeout: 5000 });
|
||||
const abisko = within(abiskoTitle.closest('li') as HTMLElement);
|
||||
expect(abisko.getByText('Public domain')).toBeInTheDocument();
|
||||
expect(abisko.getAllByRole('link')).toHaveLength(1);
|
||||
|
||||
const zinnen = within(
|
||||
screen.getByRole('link', { name: 'Drei Zinnen Tre Cime di Lavaredo Dolomites' }).closest('li') as HTMLElement
|
||||
);
|
||||
expect(zinnen.getByText('by Wolfgang Moroder')).toBeInTheDocument();
|
||||
expect(zinnen.getByRole('link', { name: 'CC BY-SA 3.0' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://creativecommons.org/licenses/by-sa/3.0/'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { ArrowLeft, ArrowRight, Clock, ExternalLink, Images, RotateCcw } from 'lucide-react';
|
||||
import { lazy, Suspense, useId } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
fs,
|
||||
NEUTRAL_TINT,
|
||||
PILL,
|
||||
} from '../shared/DialogShell';
|
||||
import ErrorBoundary from '../shared/ErrorBoundary';
|
||||
import TrekIcon from '../shared/TrekIcon';
|
||||
import { DEMO_FEATURES, DEMO_FULL_VERSION, DEMO_LIMITS, TREK_REPO_URL, type DemoLine } from './demoWelcomeModel';
|
||||
import { useDemoWelcome } from './useDemoWelcome';
|
||||
|
||||
// The credits and their photo list load when a visitor opens them, not with the dashboard.
|
||||
const DemoCredits = lazy(() => import('./DemoCredits'));
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
const FOOTER_LINK = 'inline-flex items-center gap-1.5 font-medium text-content-muted no-underline hover:text-content';
|
||||
|
||||
/** The tiles: what a visitor can open right now, one card each. */
|
||||
function FeatureGrid({ t }: { t: Translate }) {
|
||||
return (
|
||||
<div className="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{DEMO_FEATURES.map(({ id, icon: Icon, titleKey, bodyKey }) => (
|
||||
<div key={id} className="flex gap-3 rounded-[16px] border border-edge-faint bg-surface-secondary p-3.5">
|
||||
<span className="grid h-9 w-9 flex-none place-items-center rounded-[12px] bg-surface-card text-content shadow-sm">
|
||||
<Icon size={17} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-content" style={fs(13.5, 'body')}>
|
||||
{t(titleKey)}
|
||||
</div>
|
||||
<p className="m-0 mt-0.5 leading-snug text-content-muted" style={fs(12, 'body')}>
|
||||
{t(bodyKey)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A short list with an icon per line, for what the demo leaves out and what the full version adds. */
|
||||
function LineList({ lines, tone, t }: { lines: readonly DemoLine[]; tone: 'muted' | 'accent'; t: Translate }) {
|
||||
const iconClass = tone === 'accent' ? 'text-success' : 'text-content-faint';
|
||||
return (
|
||||
<ul className="m-0 flex list-none flex-col gap-2 rounded-[16px] border border-edge-faint bg-surface-card p-3.5">
|
||||
{lines.map(({ icon: Icon, labelKey }) => (
|
||||
<li key={labelKey} className="flex items-center gap-2.5 text-content-secondary" style={fs(12.5, 'body')}>
|
||||
<Icon size={15} strokeWidth={2} className={`flex-none ${iconClass}`} aria-hidden />
|
||||
<span>{t(labelKey)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** The welcome itself: the hourly reset, the tiles, and the two lists under them. */
|
||||
function WelcomeBody({ t }: { t: Translate }) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start gap-3 rounded-[16px] bg-warning-soft px-4 py-3">
|
||||
<RotateCcw size={17} strokeWidth={2.2} className="mt-0.5 flex-none text-warning" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-content" style={fs(13.5, 'body')}>
|
||||
{t('demo.welcome.resetTitle')}
|
||||
</div>
|
||||
<p className="m-0 mt-0.5 leading-snug text-content-secondary" style={fs(12.5, 'body')}>
|
||||
{t('demo.welcome.resetBody')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogSection label={t('demo.welcome.tryTitle')}>
|
||||
<FeatureGrid t={t} />
|
||||
</DialogSection>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<DialogSection label={t('demo.welcome.limitsTitle')}>
|
||||
<LineList lines={DEMO_LIMITS} tone="muted" t={t} />
|
||||
</DialogSection>
|
||||
<DialogSection label={t('demo.welcome.fullTitle')}>
|
||||
<LineList lines={DEMO_FULL_VERSION} tone="accent" t={t} />
|
||||
</DialogSection>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The footer's links: the source code, and the credits for the demo's photos.
|
||||
* One above the other on a phone, where the footer has no room for both
|
||||
* beside the button.
|
||||
*/
|
||||
function FooterLinks({ t, onCredits, focusCredits }: { t: Translate; onCredits: () => void; focusCredits: boolean }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-start gap-1.5 sm:flex-row sm:items-center sm:gap-4">
|
||||
<a
|
||||
href={TREK_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={FOOTER_LINK}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<ExternalLink size={15} strokeWidth={2} aria-hidden />
|
||||
{t('demo.welcome.repo')}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCredits}
|
||||
autoFocus={focusCredits}
|
||||
className={FOOTER_LINK}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<Images size={15} strokeWidth={2} aria-hidden />
|
||||
{t('demo.welcome.credits')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The welcome a demo instance opens with: what TREK is, that the demo resets
|
||||
* on the hour (with the live countdown), what to try right away, what the
|
||||
* demo leaves out and what the full version adds. The photo credits open in
|
||||
* its place and back. Closed by the button, Escape or a click on the
|
||||
* backdrop, and shown again on the next visit.
|
||||
*/
|
||||
export default function DemoWelcome() {
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
const { open, close, countdown, creditsOpen, backFromCredits, openCredits, closeCredits } = useDemoWelcome();
|
||||
|
||||
return (
|
||||
<DialogShell
|
||||
open={open}
|
||||
onClose={close}
|
||||
labelledBy={labelId}
|
||||
width="wide"
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<TrekIcon className="h-7 w-7 text-content" />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={close}
|
||||
eyebrow={t('demo.welcome.eyebrow')}
|
||||
title={t(creditsOpen ? 'demo.credits.title' : 'demo.welcome.title')}
|
||||
sub={creditsOpen ? undefined : t('demo.welcome.tagline')}
|
||||
subWraps
|
||||
pills={
|
||||
<span className={PILL}>
|
||||
<Clock size={13} strokeWidth={2.2} aria-hidden />
|
||||
{t('demo.welcome.resetIn')}
|
||||
<span className="font-geist tabular-nums" data-testid="demo-countdown">
|
||||
{countdown}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
{/* The start button keeps its place in both views, so switching does not move the focus onto it again. */}
|
||||
{creditsOpen ? (
|
||||
<DialogButton
|
||||
onClick={closeCredits}
|
||||
autoFocus
|
||||
icon={<ArrowLeft size={15} strokeWidth={2.2} className="rtl:-scale-x-100" />}
|
||||
>
|
||||
{t('demo.credits.back')}
|
||||
</DialogButton>
|
||||
) : (
|
||||
<FooterLinks t={t} onCredits={openCredits} focusCredits={backFromCredits} />
|
||||
)}
|
||||
<FooterSpacer />
|
||||
<DialogButton variant="primary" onClick={close} autoFocus icon={<ArrowRight size={15} strokeWidth={2.2} />}>
|
||||
{t('demo.welcome.start')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
{creditsOpen ? (
|
||||
<ErrorBoundary boundaryId="demo-credits">
|
||||
<Suspense fallback={null}>
|
||||
<DemoCredits />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
) : (
|
||||
<WelcomeBody t={t} />
|
||||
)}
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PHOTO_CREDITS, PUBLIC_DOMAIN, commonsPage, creditTitle, licenseDeed } from './demoCreditsModel';
|
||||
|
||||
describe('demo photo credits', () => {
|
||||
it('FE-DEMO-CREDITS-001: credits every photo of the demo content, each file once', () => {
|
||||
expect(PHOTO_CREDITS).toHaveLength(228);
|
||||
expect(new Set(PHOTO_CREDITS.map((c) => c.file)).size).toBe(228);
|
||||
for (const { file } of PHOTO_CREDITS) expect(file, file).toMatch(/\.jpe?g$/i);
|
||||
});
|
||||
|
||||
it('FE-DEMO-CREDITS-002: every author is a plain name, with no Commons notice or link label left', () => {
|
||||
for (const { file, author } of PHOTO_CREDITS) {
|
||||
expect(author.trim(), file).not.toBe('');
|
||||
expect(author, file).toBe(author.trim());
|
||||
expect(author.length, `${file}: ${author}`).toBeLessThanOrEqual(60);
|
||||
expect(author, file).not.toMatch(/\s{2,}|\n/);
|
||||
expect(author, file).not.toMatch(
|
||||
/Photo was taken|machine-readable|assumed|Feel free|copyright|https?:|User:|\((?:Website|Flickr)\)/i
|
||||
);
|
||||
}
|
||||
const authorOf = (file: string) => PHOTO_CREDITS.find((c) => c.file === file)?.author;
|
||||
expect(authorOf('Drei Zinnen Tre Cime di Lavaredo Dolomites.jpg')).toBe('Wolfgang Moroder');
|
||||
expect(authorOf('Iceland Godafoss 1972.jpg')).toBe('Roger McLassus');
|
||||
expect(authorOf('Arashiyama Bamboo Grove (Unsplash).jpg')).toBe('Erol Ahmed');
|
||||
});
|
||||
|
||||
it('FE-DEMO-CREDITS-003: every license has its deed, except the public domain', () => {
|
||||
for (const { file, license } of PHOTO_CREDITS) {
|
||||
if (license === PUBLIC_DOMAIN) expect(licenseDeed(license), file).toBeUndefined();
|
||||
else expect(licenseDeed(license), `${file}: ${license}`).toMatch(/^https:\/\/creativecommons\.org\/.+\/$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-DEMO-CREDITS-004: derives the deed from the license name', () => {
|
||||
expect(licenseDeed('CC BY-SA 4.0')).toBe('https://creativecommons.org/licenses/by-sa/4.0/');
|
||||
expect(licenseDeed('CC BY 2.5')).toBe('https://creativecommons.org/licenses/by/2.5/');
|
||||
expect(licenseDeed('CC BY-SA 2.0 de')).toBe('https://creativecommons.org/licenses/by-sa/2.0/de/');
|
||||
expect(licenseDeed('CC0')).toBe('https://creativecommons.org/publicdomain/zero/1.0/');
|
||||
expect(licenseDeed(PUBLIC_DOMAIN)).toBeUndefined();
|
||||
expect(licenseDeed('GFDL')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FE-DEMO-CREDITS-005: shows the title without its extension and links the page on Commons', () => {
|
||||
expect(creditTitle('Café A Brasileira. Sculpture.jpg')).toBe('Café A Brasileira. Sculpture');
|
||||
expect(creditTitle('Svínafellsjökull.JPG')).toBe('Svínafellsjökull');
|
||||
expect(creditTitle('No extension')).toBe('No extension');
|
||||
expect(commonsPage('Lagoa Rasa (Sete Cidades) 2.jpg')).toBe(
|
||||
'https://commons.wikimedia.org/wiki/File:Lagoa_Rasa_(Sete_Cidades)_2.jpg'
|
||||
);
|
||||
expect(commonsPage('Glacier du Géant & Mont Blanc.jpg')).toBe(
|
||||
'https://commons.wikimedia.org/wiki/File:Glacier_du_G%C3%A9ant_%26_Mont_Blanc.jpg'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import credits from './photoCredits.json';
|
||||
|
||||
/**
|
||||
* One photo of the demo content as Wikimedia Commons credits it: the file's
|
||||
* name there, its author and the short name of its license. The page on
|
||||
* Commons, the title to show and the license's deed all follow from these, so
|
||||
* photoCredits.json holds nothing else.
|
||||
*/
|
||||
export interface PhotoCredit {
|
||||
file: string;
|
||||
author: string;
|
||||
license: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every photo in the demo content, sorted by file name. The authors are the
|
||||
* plain names: where Commons fills the field with a notice or a link label
|
||||
* ("This Photo was taken by ...", "(Flickr)"), the list holds the name it
|
||||
* gives, and where the author asks to be credited in a set form, that form.
|
||||
*/
|
||||
export const PHOTO_CREDITS: readonly PhotoCredit[] = credits;
|
||||
|
||||
/** The license name Commons gives a photo nobody holds a copyright in; it has no deed to link. */
|
||||
export const PUBLIC_DOMAIN = 'Public domain';
|
||||
|
||||
const COMMONS_FILE_PAGE = 'https://commons.wikimedia.org/wiki/File:';
|
||||
|
||||
/** The file's name without its extension, the title Commons shows above the photo. */
|
||||
export function creditTitle(file: string): string {
|
||||
const dot = file.lastIndexOf('.');
|
||||
return dot > 0 ? file.slice(0, dot) : file;
|
||||
}
|
||||
|
||||
/** The file's page on Commons, with its full description and license. */
|
||||
export function commonsPage(file: string): string {
|
||||
return COMMONS_FILE_PAGE + encodeURIComponent(file.replace(/ /g, '_'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The deed of a Creative Commons license by the short name Commons gives it:
|
||||
* "CC BY-SA 4.0", a ported one such as "CC BY-SA 2.0 de", or "CC0". Nothing
|
||||
* for the public domain, which is no license.
|
||||
*/
|
||||
export function licenseDeed(license: string): string | undefined {
|
||||
if (license === 'CC0') return 'https://creativecommons.org/publicdomain/zero/1.0/';
|
||||
const match = /^CC (BY(?:-SA)?) (\d\.\d)(?: ([a-z]{2}))?$/.exec(license);
|
||||
if (!match) return undefined;
|
||||
const [, terms, version, port] = match;
|
||||
return `https://creativecommons.org/licenses/${terms.toLowerCase()}/${version}/${port ? `${port}/` : ''}`;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import en from '@trek/shared/i18n/en';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DEMO_FEATURES, DEMO_FULL_VERSION, DEMO_LIMITS, formatCountdown, secondsUntilReset } from './demoWelcomeModel';
|
||||
|
||||
describe('demo welcome model', () => {
|
||||
it('FE-DEMO-MODEL-001: every tile and line names a key the English catalogue holds', () => {
|
||||
const keys = [
|
||||
...DEMO_FEATURES.flatMap((f) => [f.titleKey, f.bodyKey]),
|
||||
...DEMO_LIMITS.map((l) => l.labelKey),
|
||||
...DEMO_FULL_VERSION.map((l) => l.labelKey),
|
||||
];
|
||||
for (const key of keys) expect(Object.keys(en), key).toContain(key);
|
||||
});
|
||||
|
||||
it('FE-DEMO-MODEL-002: shows the highlights a visitor can open, each once', () => {
|
||||
expect(DEMO_FEATURES.map((f) => f.id)).toEqual([
|
||||
'planner',
|
||||
'tours',
|
||||
'studio',
|
||||
'collab',
|
||||
'journey',
|
||||
'atlas',
|
||||
'vacay',
|
||||
'collections',
|
||||
'offline',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-DEMO-MODEL-003: names everything the demo account cannot do', () => {
|
||||
expect(DEMO_LIMITS.map((l) => l.labelKey)).toEqual([
|
||||
'demo.limit.uploads',
|
||||
'demo.limit.account',
|
||||
'demo.limit.apiKeys',
|
||||
'demo.limit.mcp',
|
||||
'demo.limit.ai',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-DEMO-MODEL-004: counts down to the next full hour in the server zone', () => {
|
||||
const at = new Date(Date.UTC(2026, 9, 24, 12, 17, 53));
|
||||
expect(secondsUntilReset(at, 'UTC')).toBe(42 * 60 + 7);
|
||||
// A zone half an hour off UTC has its hour mark at half past, UTC time.
|
||||
expect(secondsUntilReset(at, 'Asia/Kolkata')).toBe(12 * 60 + 7);
|
||||
});
|
||||
|
||||
it('FE-DEMO-MODEL-005: falls back to the browser clock for a zone it does not know, or none', () => {
|
||||
const at = new Date(2026, 9, 24, 12, 59, 30);
|
||||
expect(secondsUntilReset(at, 'Not/AZone')).toBe(30);
|
||||
expect(secondsUntilReset(at)).toBe(30);
|
||||
expect(secondsUntilReset(new Date(2026, 9, 24, 13, 0, 0))).toBe(3600);
|
||||
});
|
||||
|
||||
it('FE-DEMO-MODEL-006: formats the countdown as minutes and seconds', () => {
|
||||
expect(formatCountdown(42 * 60 + 7)).toBe('42:07');
|
||||
expect(formatCountdown(5)).toBe('00:05');
|
||||
expect(formatCountdown(3600)).toBe('60:00');
|
||||
expect(formatCountdown(-3)).toBe('00:00');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
BookImage,
|
||||
Bookmark,
|
||||
Bot,
|
||||
CalendarDays,
|
||||
DatabaseBackup,
|
||||
Globe,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Map as MapIcon,
|
||||
Mountain,
|
||||
NotebookPen,
|
||||
Puzzle,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Upload,
|
||||
UserLock,
|
||||
Users,
|
||||
WifiOff,
|
||||
} from 'lucide-react';
|
||||
|
||||
/** One thing a visitor can try in the demo right away: a tile of the welcome dialog. */
|
||||
interface DemoFeature {
|
||||
id: string;
|
||||
icon: LucideIcon;
|
||||
titleKey: string;
|
||||
bodyKey: string;
|
||||
}
|
||||
|
||||
/** A line of the two lists under the tiles: what the demo leaves out, and what the full version adds. */
|
||||
export interface DemoLine {
|
||||
icon: LucideIcon;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export const DEMO_FEATURES: readonly DemoFeature[] = [
|
||||
{ id: 'planner', icon: MapIcon, titleKey: 'demo.feature.planner.title', bodyKey: 'demo.feature.planner.body' },
|
||||
{ id: 'tours', icon: Mountain, titleKey: 'demo.feature.tours.title', bodyKey: 'demo.feature.tours.body' },
|
||||
{ id: 'studio', icon: BookImage, titleKey: 'demo.feature.studio.title', bodyKey: 'demo.feature.studio.body' },
|
||||
{ id: 'collab', icon: Users, titleKey: 'demo.feature.collab.title', bodyKey: 'demo.feature.collab.body' },
|
||||
{ id: 'journey', icon: NotebookPen, titleKey: 'demo.feature.journey.title', bodyKey: 'demo.feature.journey.body' },
|
||||
{ id: 'atlas', icon: Globe, titleKey: 'demo.feature.atlas.title', bodyKey: 'demo.feature.atlas.body' },
|
||||
{ id: 'vacay', icon: CalendarDays, titleKey: 'demo.feature.vacay.title', bodyKey: 'demo.feature.vacay.body' },
|
||||
{
|
||||
id: 'collections',
|
||||
icon: Bookmark,
|
||||
titleKey: 'demo.feature.collections.title',
|
||||
bodyKey: 'demo.feature.collections.body',
|
||||
},
|
||||
{ id: 'offline', icon: WifiOff, titleKey: 'demo.feature.offline.title', bodyKey: 'demo.feature.offline.body' },
|
||||
];
|
||||
|
||||
/** What the shared demo account cannot do; the server refuses each of them too. */
|
||||
export const DEMO_LIMITS: readonly DemoLine[] = [
|
||||
{ icon: Upload, labelKey: 'demo.limit.uploads' },
|
||||
{ icon: UserLock, labelKey: 'demo.limit.account' },
|
||||
{ icon: KeyRound, labelKey: 'demo.limit.apiKeys' },
|
||||
{ icon: Terminal, labelKey: 'demo.limit.mcp' },
|
||||
{ icon: Sparkles, labelKey: 'demo.limit.ai' },
|
||||
];
|
||||
|
||||
export const DEMO_FULL_VERSION: readonly DemoLine[] = [
|
||||
{ icon: ShieldCheck, labelKey: 'demo.full.sso' },
|
||||
{ icon: DatabaseBackup, labelKey: 'demo.full.backups' },
|
||||
{ icon: Puzzle, labelKey: 'demo.full.plugins' },
|
||||
{ icon: Bot, labelKey: 'demo.full.assistant' },
|
||||
{ icon: LayoutDashboard, labelKey: 'demo.full.admin' },
|
||||
];
|
||||
|
||||
export const TREK_REPO_URL = 'https://github.com/liketrek/TREK';
|
||||
|
||||
/** The demo resets on the hour (the server's `0 * * * *` cron). */
|
||||
const HOUR_SECONDS = 3600;
|
||||
|
||||
/**
|
||||
* Minute and second of the hour at `now` in the server's time zone, where the
|
||||
* reset cron runs. A zone whose offset is not whole hours (India, Nepal) moves
|
||||
* the hour mark; an unknown zone falls back to the browser's clock.
|
||||
*/
|
||||
function minuteAndSecond(now: Date, timeZone: string | undefined): [number, number] {
|
||||
if (timeZone) {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('en-US', { timeZone, minute: 'numeric', second: 'numeric' }).formatToParts(
|
||||
now
|
||||
);
|
||||
const read = (type: 'minute' | 'second') => Number(parts.find((p) => p.type === type)?.value);
|
||||
const minute = read('minute');
|
||||
const second = read('second');
|
||||
if (Number.isFinite(minute) && Number.isFinite(second)) return [minute, second];
|
||||
} catch {
|
||||
// A zone this browser does not know: the local clock below.
|
||||
}
|
||||
}
|
||||
return [now.getMinutes(), now.getSeconds()];
|
||||
}
|
||||
|
||||
/** Seconds until the next hourly reset, from 1 up to 3600. */
|
||||
export function secondsUntilReset(now: Date, timeZone?: string): number {
|
||||
const [minute, second] = minuteAndSecond(now, timeZone);
|
||||
return HOUR_SECONDS - (minute * 60 + second);
|
||||
}
|
||||
|
||||
/** The countdown as minutes and seconds, `42:07`. */
|
||||
export function formatCountdown(seconds: number): string {
|
||||
const safe = Math.max(0, Math.floor(seconds));
|
||||
const minutes = Math.floor(safe / 60);
|
||||
const rest = safe % 60;
|
||||
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
[
|
||||
{
|
||||
"file": "(Venice) - Fondamente zattere - Casa Scarpa.jpg",
|
||||
"author": "Didier Descouens",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{
|
||||
"file": "+Die Hallgrimskirkja ist das markanteste Bauwerk Reykjaviks. 03.jpg",
|
||||
"author": "Holger Uwe Schmitt",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{
|
||||
"file": "+Die Hallgrimskirkja ist das markanteste Bauwerk Reykjaviks. 05.jpg",
|
||||
"author": "Holger Uwe Schmitt",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{
|
||||
"file": "021 Wild smiling harbor seal at Jökulsárlón (Iceland) Photo by Giles Laurent.jpg",
|
||||
"author": "Giles Laurent",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "03613 ISL Akureyri cruise vessel vapors V P.jpg", "author": "Virtual-Pano", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "20181110 Fushimi Inari shrine 3.jpg", "author": "Balon Greyjoy", "license": "CC0" },
|
||||
{ "file": "20181110 Fushimi Inari shrine 4.jpg", "author": "Balon Greyjoy", "license": "CC0" },
|
||||
{ "file": "20181110 Fushimi Inari shrine 6.jpg", "author": "Balon Greyjoy", "license": "CC0" },
|
||||
{ "file": "2019-08-01 Zugspitze 7.jpg", "author": "Maksym Kozlenko", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "2022-05-28 13-21-39 Heinabergslón and Heinabergsjökull.jpg",
|
||||
"author": "Hansueli Krapf",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "2022-05-28 13-26-26 Island - Hoffell 118.0.jpg", "author": "Hansueli Krapf", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "2022-05-28 13-50-35 Panorama of Heinabergsjökull.jpg",
|
||||
"author": "Hansueli Krapf",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "2023-11-04 Lycian Way near Kaş.jpg", "author": "Alexkom000", "license": "CC BY 4.0" },
|
||||
{ "file": "AbiskoNationalpark01.jpg", "author": "Unterrichter at English Wikipedia", "license": "Public domain" },
|
||||
{ "file": "Almannagjá gorge at Þingvellir National Park.jpg", "author": "Olga Ernst", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Almannagjá rift at Þingvellir National Park.jpg", "author": "Olga Ernst", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Arashiyama Bamboo Grove (Unsplash).jpg", "author": "Erol Ahmed", "license": "CC0" },
|
||||
{ "file": "Arashiyama Bamboo Grove.jpg", "author": "Mitchwandrew", "license": "CC BY 4.0" },
|
||||
{ "file": "Arashiyama-Bamboo-Grove-Sunset.jpg", "author": "Bjørn Christian Tørrissen", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "ArashiyamaTogetsukyo Kyoto.jpg", "author": "Kentagon", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Arnarstapi (port).jpg", "author": "Chmee2/Valtameri", "license": "CC BY 3.0" },
|
||||
{ "file": "Arnarstapi 2.jpg", "author": "Christian Bickel", "license": "CC BY-SA 2.0 de" },
|
||||
{ "file": "Arnarstapi 4.jpg", "author": "Christian Bickel", "license": "CC BY-SA 2.0 de" },
|
||||
{
|
||||
"file": "Averau from Fedare at Passo Giau, Belluno, Veneto, Italy, 2025 October.jpg",
|
||||
"author": "Simo Räsänen",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Bamboo Forest, Arashiyama, Kyoto, Japan.jpg", "author": "Basile Morin", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Bamboo Grove, Arashiyama, Kyoto, Japan.jpg", "author": "Basile Morin", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Basalt Arch near Arnarstapi.jpg", "author": "Remarksman", "license": "CC BY 2.0" },
|
||||
{
|
||||
"file": "Basilica di Santa Maria della Salute, Venice (37770336971).jpg",
|
||||
"author": "Dimitris Kamaras from Athens, Greece",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{ "file": "Black Sand Beach Iceland, Reynisfjara Beach.jpg", "author": "miketnorton", "license": "CC BY 2.0" },
|
||||
{ "file": "Bubbly Diamond on the Beach - Iceland.jpg", "author": "Eric Kilby", "license": "CC BY-SA 2.0" },
|
||||
{
|
||||
"file": "Burano Hausfassade Wäsche-20090315-RM-113017.jpg",
|
||||
"author": "Reinhold Möller",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Burano Hausfassade Wäsche-20090315-RM-113043.jpg",
|
||||
"author": "Reinhold Möller",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Burano Vendig Aug 2021 4.jpg", "author": "Kasa Fue", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Café A Brasileira. Sculpture.jpg", "author": "MFREYNAUD51", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Café de Flore, 172 boulevard Saint-Germain, Paris 6e.jpg",
|
||||
"author": "Celette",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Café Majestic - Porto - Portugal (53283088293).jpg",
|
||||
"author": "Vitor Oliveira from Torres Vedras, PORTUGAL",
|
||||
"license": "CC BY-SA 2.0"
|
||||
},
|
||||
{ "file": "Café Sperl, Wien.jpg", "author": "Jeremy Thompson", "license": "CC BY 2.0" },
|
||||
{ "file": "Caffè San Marco-Trieste.jpg", "author": "Tiesse", "license": "Public domain" },
|
||||
{ "file": "Caffè Sant'Eustachio.JPG", "author": "LPLT", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Canale Grande in the morning light - panoramio.jpg", "author": "Schölla Schwarz", "license": "CC BY 3.0" },
|
||||
{ "file": "Cinque Torri 01.jpg", "author": "Clemens Stockner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Cinque Torri 02.jpg", "author": "Clemens Stockner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Cinque Torri 1.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Cinque Torri, Dolomites - panoramio (1).jpg", "author": "Petr Kraumann", "license": "CC BY 3.0" },
|
||||
{ "file": "Cinque Torri, Dolomites - panoramio (2).jpg", "author": "Petr Kraumann", "license": "CC BY 3.0" },
|
||||
{
|
||||
"file": "Colourful houses in Corte della Comare, Burano (detail).jpg",
|
||||
"author": "Marco Ober",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Croda del Becco and Rifugio Biella 202008.jpg", "author": "Marie-Lan Taÿ Pamart", "license": "CC BY 4.0" },
|
||||
{
|
||||
"file": "Cuernos del Paine in Torres del Paine National Park.jpg",
|
||||
"author": "Pedro Szekely from Los Angeles, USA",
|
||||
"license": "CC BY-SA 2.0"
|
||||
},
|
||||
{ "file": "Dettifoss 2026 02.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Dettifoss Panorama 01.jpg", "author": "Spike", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Dettifoss Panorama 02.jpg", "author": "Spike", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Dettifoss Panorama 03.jpg", "author": "Spike", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Dorsoduro Rio dei Carmini in Venice.jpg", "author": "Wolfgang Moroder", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Dotonbori Osaka Japan01-r.jpg", "author": "Oilstreet", "license": "CC BY 2.5" },
|
||||
{ "file": "Dotonbori, Osaka, at night, November 2016.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Drei Zinnen Tre Cime di Lavaredo Dolomites.jpg", "author": "Wolfgang Moroder", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Dyrholaey Arch.jpg", "author": "Eric Kilby", "license": "CC BY-SA 2.0" },
|
||||
{
|
||||
"file": "Dyrhólaey, seen from Reynisfjara, Iceland, 20240720 0831 2824.jpg",
|
||||
"author": "Jakub Hałun",
|
||||
"license": "CC BY 4.0"
|
||||
},
|
||||
{ "file": "Eibsee und Ammergauer Alpen.jpg", "author": "Octagon", "license": "CC BY 3.0" },
|
||||
{
|
||||
"file": "Ëur de Bredles Secëda Odles y Sas Rigais da Resciesa.jpg",
|
||||
"author": "Wolfgang Moroder",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Faloria Cortina d'Ampezzo 10.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Falzarego-N.jpg", "author": "Supakon Mohr", "license": "CC BY-SA 3.0 de" },
|
||||
{ "file": "Fimmvörðuháls (1).jpg", "author": "Chmee2/Valtameri", "license": "CC BY 3.0" },
|
||||
{ "file": "Fimmvörðuháls (9).jpg", "author": "Chmee2/Valtameri", "license": "CC BY 3.0" },
|
||||
{
|
||||
"file": "Fimmvorduhals-emergency-hut.jpg",
|
||||
"author": "Reen Eversdijk from Groningen, The Netherlands",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{ "file": "Fjaðrárgljúfur 04.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Forcella Nuvolau–Nuvolau lodge trail.jpg", "author": "Marco Ober", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Four ladies wearing a yukata in front of the North Gate of Kiyomizu-dera temple Kyoto Japan.jpg",
|
||||
"author": "Basile Morin",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Frea pass Sella group Pisciadu Dolomites South Tyrol.jpg",
|
||||
"author": "Wolfgang Moroder",
|
||||
"license": "CC BY-SA 3.0"
|
||||
},
|
||||
{ "file": "Funivia del Lagazuoi (passo Falzarego).jpg", "author": "Ale041VE", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Gion Hanamikôji-dôri.jpg", "author": "Yanajin33", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Glacier du Mont Mallet (Glacier du Géant) & Mont Blanc, 2010 July.jpg",
|
||||
"author": "Ximonic, Simo Räsänen",
|
||||
"license": "CC BY-SA 3.0"
|
||||
},
|
||||
{ "file": "Godafoss 2.jpg", "author": "Tord Dellsen", "license": "CC0" },
|
||||
{ "file": "Godafoss 3.jpg", "author": "Tord Dellsen", "license": "CC0" },
|
||||
{ "file": "Godafoss 4.jpg", "author": "Tord Dellsen", "license": "CC0" },
|
||||
{ "file": "Goðafoss Island.jpg", "author": "Superbass", "license": "CC BY-SA 3.0" },
|
||||
{
|
||||
"file": "Gondola Guido Lorenzi - Son Forca Cristallo Italy - panoramio.jpg",
|
||||
"author": "Hiroki Ogawa",
|
||||
"license": "CC BY 3.0"
|
||||
},
|
||||
{ "file": "GR20 Corsica Montains-2.jpg", "author": "Hermes from mars", "license": "CC BY-SA 3.0" },
|
||||
{
|
||||
"file": "Grand Canal Santa Maria della Salute Vaporetto Gondola Venice 01.jpg",
|
||||
"author": "Julian Herzog",
|
||||
"license": "CC BY 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Gullfoss, Suðurland, Islandia, 2014-08-16, DD 119.JPG",
|
||||
"author": "Diego Delso",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Hallgrímskirkja 1.jpg", "author": "Dmitry Brant", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Heinabergsjökull (38322450122).jpg", "author": "Markus Trienke", "license": "CC BY-SA 2.0" },
|
||||
{ "file": "Hverfjall July 2014.JPG", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Hverir Námaskarð.jpg", "author": "JavierOlivares", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Iceland Dettifoss 1972-4.jpg", "author": "Roger McLassus", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Iceland Godafoss 1972.jpg", "author": "Roger McLassus", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Il Sentiero degli dei.jpg", "author": "Stiflele", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Illusion, Burano Island. (39893205060).jpg",
|
||||
"author": "Mihai Lucîț from Baia Mare, Romania",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{ "file": "Islanda- Þórsmörk.jpg", "author": "Mario Falcetti", "license": "CC BY 4.0" },
|
||||
{
|
||||
"file": "Jökulsárlón glacier lagoon, Iceland, 20240719 0935 2448.jpg",
|
||||
"author": "Jakub Hałun",
|
||||
"license": "CC BY 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Jökulsárlón glacier lagoon, Iceland, 20240719 0936 2452.jpg",
|
||||
"author": "Jakub Hałun",
|
||||
"license": "CC BY 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Jökulsárlón glacier lagoon, Iceland, 20240719 0937 2454.jpg",
|
||||
"author": "Jakub Hałun",
|
||||
"license": "CC BY 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Jökulsárlón glacier lagoon, Iceland, 20240719 0947 2477.jpg",
|
||||
"author": "Jakub Hałun",
|
||||
"license": "CC BY 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Kansai Airport (2660200665).jpg",
|
||||
"author": "Kentaro Iemoto from Tokyo, Japan",
|
||||
"license": "CC BY-SA 2.0"
|
||||
},
|
||||
{ "file": "Kinkaku-ji in November 2016 -02.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Kinkaku-ji the Golden Temple in Kyoto overlooking the lake - high rez.JPG",
|
||||
"author": "Jaycangel",
|
||||
"license": "CC BY-SA 3.0"
|
||||
},
|
||||
{ "file": "Kirkjufell in Fog (Unsplash).jpg", "author": "Nicholas Kampouris", "license": "CC0" },
|
||||
{ "file": "Kirkjufell with its reflection.jpg", "author": "_大面", "license": "CC BY 2.5" },
|
||||
{ "file": "Kirkjufell, Iceland (Unsplash LoKG7kKx7Zc).jpg", "author": "Steven Diaz", "license": "CC0" },
|
||||
{
|
||||
"file": "Kirkjufell, Vesturland, Islandia, 2014-08-14, DD 091.JPG",
|
||||
"author": "Diego Delso",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Kiyomizu-dera, Kyoto, November 2016 -01.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Kiyomizu-dera, Kyoto, November 2016 -02.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Kiyomizu-dera, Kyoto, November 2016 -03.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Kiyomizu-dera, Kyoto, November 2016 -06.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Kiyomizu-dera, Kyoto, November 2016 -07.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "KyotoFushimiInariLarge.jpg", "author": "Paul Vlaar", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Lagazuoi 05.jpg", "author": "Clemens Stockner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Lago alla base di Svínafellsjökull 01.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Lago alla base di Svínafellsjökull 02.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Lago di Braies South Tyrol 3.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Lago di Braies South Tyrol 5.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Lago di Braies South Tyrol 6.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Lago di Braies South Tyrol 7.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Lago di Braies South Tyrol 9.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Lago di Limo, Parco Naturale Fanes - Sennes - Braies (BZ).jpg",
|
||||
"author": "53cri021",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Lagoa Rasa (Sete Cidades) 2.jpg", "author": "Walmaul", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Lake Ashi & Mt Fuji & Hakone Shrine.jpg", "author": "Charlie fong", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Landmannalaugar in summer 2009 (13).jpg", "author": "Chmee2/Valtameri", "license": "CC BY 3.0" },
|
||||
{
|
||||
"file": "Manarola NW Cemetery Corniglia Monterosso Cinque Terre Sep23 A7C 06872.jpg",
|
||||
"author": "Timothy A. Gonsalves",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Meiji Jingu Shrine Tokyo Japan.jpg", "author": "MediaByPanda", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Meiji shrine in August 2007 5.jpg", "author": "Syced", "license": "CC0" },
|
||||
{ "file": "Meiji Shrine, Tokyo; April 2009 (02).jpg", "author": "MIKI Yoshihito", "license": "CC BY 2.0" },
|
||||
{
|
||||
"file": "Meiji-jingū main hall vue centrale.jpg",
|
||||
"author": "Hyppolyte de Saint-Rambert",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Meiji-jingu-pathway.jpg", "author": "Nightcrafter", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Monte Kirkjufell (Luglio 2024).jpg", "author": "Paolova97", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Morning light in Venice (33726527456).jpg", "author": "Hervé Simon", "license": "CC BY-SA 2.0" },
|
||||
{ "file": "Mount Fuji and Shinkansen N700.jpg", "author": "Alpsdake", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Mount Myojingatake and Owakudani Station from Owakudani Valley.JPG",
|
||||
"author": "そらみみ",
|
||||
"license": "CC BY-SA 3.0"
|
||||
},
|
||||
{ "file": "Mount-Fuji from Tokaido Shinkansen window view.jpg", "author": "Indiana jo", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Mountain and grotto at Reynisfjara Beach in July, 2019.jpg",
|
||||
"author": "Alexander Grebenkov",
|
||||
"license": "CC BY 3.0"
|
||||
},
|
||||
{ "file": "Múlafossur2.jpg", "author": "Ekrem Canli", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Neon Signs in Dotonbori, Osaka 20190415 1.jpg", "author": "DXR", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Neunerspitze, 2968 m und Zehnerspitze 3016 m.jpg",
|
||||
"author": "2015 Michael 2015",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "NeunerZehnerFanes.JPG", "author": "Alex1011", "license": "CC BY 2.5" },
|
||||
{ "file": "Nishiki ichiba kyoto.jpg", "author": "Pitan", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Old Tokaido - Hakone.jpg", "author": "Quercus acuta", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Old Tokaido -01.jpg", "author": "Aimaimyi", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "On the hillside above Ginkaku-ji (2099332601).jpg", "author": "KimonBerlin", "license": "CC BY-SA 2.0" },
|
||||
{ "file": "Osaka Dotonbori Canal.jpg", "author": "32linesky", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Owakudani Valley and Mount Kammurigatake near Owakudani Station 1.JPG",
|
||||
"author": "そらみみ",
|
||||
"license": "CC BY-SA 3.0"
|
||||
},
|
||||
{
|
||||
"file": "Owakudani Valley and Mount Kammurigatake near Owakudani Station 2.JPG",
|
||||
"author": "そらみみ",
|
||||
"license": "CC BY-SA 3.0"
|
||||
},
|
||||
{ "file": "Owakudani Valley near Owakudani Station 2.JPG", "author": "そらみみ", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Owakudani Valley near Owakudani Station.JPG", "author": "そらみみ", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Palazzo Balbi (Venice).jpg", "author": "Didier Descouens", "license": "CC BY-SA 3.0" },
|
||||
{
|
||||
"file": "Palazzo dei Camerlenghi e Ponte Rialto a Venezia.jpg",
|
||||
"author": "Wolfgang Moroder",
|
||||
"license": "CC BY-SA 3.0"
|
||||
},
|
||||
{
|
||||
"file": "Panorama of Canal Grande and Ponte di Rialto, Venice - September 2017.jpg",
|
||||
"author": "Martin Falbisoner",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Passo di Giau.jpg", "author": "Frisia Orientalis", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Passo Giau SP638.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Photos at teamlab planets tokyo.jpg", "author": "Sasa0403", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Ponte di Rialto facciata ovest di sera.jpg", "author": "Wolfgang Moroder", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Ponte di Rialto Venice 1.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Pontile Zattere (Venezia).jpg", "author": "Didier Descouens", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Preikestolen Norge.jpg", "author": "Stefan Krause, Germany", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Ra Gusela, view from Passo Giau.jpg", "author": "Marco Ober", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Reine i Lofoten LC0148.jpg", "author": "Jörg Hempel", "license": "CC BY-SA 2.0 de" },
|
||||
{
|
||||
"file": "Reynisfjara Black Sand Beach Iceland Travel Photography (210387065).jpeg",
|
||||
"author": "Giuseppe Milo",
|
||||
"license": "CC BY 3.0"
|
||||
},
|
||||
{
|
||||
"file": "Reynisfjara, Suðurland, Islandia, 2014-08-17, DD 164.JPG",
|
||||
"author": "Diego Delso",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Reyniskirkja Church,Vík í Mýrdal, Suðurland,Ísland.jpg",
|
||||
"author": "JavierOlivares",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Rifugio Fanes 01.JPG", "author": "Bbruno", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Rifugio Fanes.jpg", "author": "Llorenzi", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Rigi Kulm cross 20210905.jpg", "author": "Daniel Kraft", "license": "CC BY-SA 3.0" },
|
||||
{
|
||||
"file": "Roca de la Ley, Parque Nacional de Þingvellir, Suðurland, Islandia, 2014-08-16, DD 022.JPG",
|
||||
"author": "Diego Delso",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Salzwelten vantage point.jpg", "author": "Grandmaster Huon", "license": "CC0" },
|
||||
{ "file": "San Marco cathedral in Venice.JPG", "author": "Petar Milosevic", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Santa Maria della Salute from Hotel Monaco nightview.jpg",
|
||||
"author": "Wolfgang Moroder",
|
||||
"license": "CC BY-SA 3.0"
|
||||
},
|
||||
{ "file": "Santa Maria della Salute from Hotel Monaco.jpg", "author": "Wolfgang Moroder", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Seljalandsfoss - panoramio (7).jpg", "author": "Zoran Kurelić Rabko", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Seljalandsfoss and Gljúfrabúi, July 2019.jpg", "author": "Alexander Grebenkov", "license": "CC BY 3.0" },
|
||||
{ "file": "Seljalandsfoss July 2014 -1.JPG", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Seljalandsfoss view from side, July 2019.jpg", "author": "Alexander Grebenkov", "license": "CC BY 3.0" },
|
||||
{
|
||||
"file": "Seljalandsfoss, Suðurland, Islandia, 2014-08-16, DD 189-191 HDR.JPG",
|
||||
"author": "Diego Delso",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Senso-ji Temple @ Asakusa (13824517393).jpg",
|
||||
"author": "Guilhem Vellut from Annecy, France",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{
|
||||
"file": "Senso-ji Temple @ Asakusa (13824525803).jpg",
|
||||
"author": "Guilhem Vellut from Annecy, France",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{
|
||||
"file": "Senso-ji Temple @ Asakusa (13824839284).jpg",
|
||||
"author": "Guilhem Vellut from Annecy, France",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{
|
||||
"file": "Senso-ji Temple @ Asakusa (14019477806).jpg",
|
||||
"author": "Guilhem Vellut from Annecy, France",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{ "file": "Seyðisfjörður church 1.jpg", "author": "Saifunny", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Seyðisfjörður Sept 2019 1.jpg", "author": "Kasa Fue", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Seydisfjordur, East Iceland (51440969579).jpg",
|
||||
"author": "Fabio Achilli from Milano, Italy",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{
|
||||
"file": "Seydisfjordur, East Iceland (51441184230).jpg",
|
||||
"author": "Fabio Achilli from Milano, Italy",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{ "file": "Seyðisfjörður, Iceland (28328269217).jpg", "author": "Joe deSousa", "license": "CC0" },
|
||||
{
|
||||
"file": "Shadows and windows at Hallgrimskirkja Reykjavik Iceland 2026-02-07 21-21-01 1.jpg",
|
||||
"author": "G. Edward Johnson",
|
||||
"license": "CC BY 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Shariden, Kinkaku-ji, Kyoto, East View 20130811 1.jpg",
|
||||
"author": "DXR / Daniel Vorndran",
|
||||
"license": "CC BY-SA 3.0"
|
||||
},
|
||||
{ "file": "Shinjuku-West Omoide-Yokocho.jpg", "author": "MaedaAkihiko", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Shinkansen N700S series photographed at Higashi-Tagonoura with Mount Fuji in the background, 10 April 2023.jpg",
|
||||
"author": "Takeshi Aida",
|
||||
"license": "CC BY-SA 2.0"
|
||||
},
|
||||
{ "file": "Skogafoss-02-2018-gje.jpg", "author": "Gerd Eichmann", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Sky Lagoon Cold Plunge.jpg", "author": "Laurenmcl", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Soca River Valley, Slovenia.jpg", "author": "Barry", "license": "CC BY 2.0" },
|
||||
{
|
||||
"file": "Start of the Old Hakone Highway (9412055176).jpg",
|
||||
"author": "Maarten Heerlien from Voorschoten, The Netherlands",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{
|
||||
"file": "Strokkur, Área geotérmica de Geysir, Suðurland, Islandia, 2014-08-16, DD 086.JPG",
|
||||
"author": "Diego Delso",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Svínafellsjökull 01.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Svínafellsjökull glacier at sunset.jpg", "author": "Mikołaj Bańka", "license": "CC BY 4.0" },
|
||||
{ "file": "Svínafellsjökull.JPG", "author": "Bromr at Dutch Wikipedia", "license": "CC BY-SA 3.0" },
|
||||
{
|
||||
"file": "Taroko-Gorge Hualien Taiwan Pudu-Bridge-at-Taroko-National-Park-01.jpg",
|
||||
"author": "CEphoto, Uwe Aranas",
|
||||
"license": "CC BY-SA 3.0"
|
||||
},
|
||||
{
|
||||
"file": "The Lava Fields Of Þórsmörk Iceland (175491845).jpeg",
|
||||
"author": "Ilya Grigorik",
|
||||
"license": "CC BY-SA 3.0"
|
||||
},
|
||||
{ "file": "The park bench (2669222928).jpg", "author": "Rosino", "license": "CC BY-SA 2.0" },
|
||||
{
|
||||
"file": "The West Highland Way approaching the Heads of Glen Etive and Glen Coe - geograph.org.uk - 5014659.jpg",
|
||||
"author": "Tim Heaton",
|
||||
"license": "CC BY-SA 2.0"
|
||||
},
|
||||
{ "file": "Tokyo Shibuya Scramble Crossing 2018-10-09.jpg", "author": "Benh LIEU SONG", "license": "CC BY-SA 2.0" },
|
||||
{
|
||||
"file": "Torii on Lake Ashi @ Hakone Shrine (13776878594).jpg",
|
||||
"author": "Guilhem Vellut from Annecy, France",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{
|
||||
"file": "Torii on Lake Ashi @ Hakone Shrine (13776879364).jpg",
|
||||
"author": "Guilhem Vellut from Annecy, France",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{
|
||||
"file": "Torii path with lantern at Fushimi Inari Taisha Shrine, Kyoto, Japan.jpg",
|
||||
"author": "Basile Morin",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Trolltunga 2.jpg", "author": "Steinar Talmoen", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Tsukiji Outer Market -04.jpg", "author": "Aimaimyi", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Tsukiji Outer Market -05.jpg", "author": "Aimaimyi", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Tsukiji Outer Market -06.jpg", "author": "Aimaimyi", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Tsukiji Outer Market 2.jpg", "author": "Aw1805", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Ushguli Svaneti Georgia.JPG", "author": "Florian Pinel", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Vaporetto grand canal venice.jpg", "author": "John Salatas", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Vaporetto, Grand Canal, Venice.jpg", "author": "Peter K Burian", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Venice, Canal Grande, Dorsoduro, Plzo Balbi.jpg", "author": "Armin Kleiner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Venice, Italy. Morning light.jpg", "author": "Dupont66", "license": "CC BY 4.0" },
|
||||
{ "file": "Via ferrata Ivano Dibona - panoramio (1).jpg", "author": "Pavel Špindler", "license": "CC BY 3.0" },
|
||||
{ "file": "Via ferrata Ivano Dibona - panoramio (2).jpg", "author": "Pavel Špindler", "license": "CC BY 3.0" },
|
||||
{ "file": "Via Ferrato Ivano Dibona 3.jpg", "author": "Schi11", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "View from Miradouro do Pico do Arieiro - Madeira 05.jpg", "author": "H. Zell", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Vire via ferrata Ivano Dibona.jpg", "author": "Evelyne Chassagneux-Bonini", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Water reflection of Kinkaku-ji Temple a sunny day, Kyoto, Japan.jpg",
|
||||
"author": "Basile Morin",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Water reflection of Kinkaku-ji Temple with blue sky and white clouds, Kyoto, Japan.jpg",
|
||||
"author": "Basile Morin",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Water transport in Venice at Rialto bridge.jpg", "author": "Wolfgang Moroder", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Waterfalls of Iceland 23 02.jpg", "author": "Hornstrandir1", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Wengental 2019-10-18.jpg", "author": "2015 Michael 2015", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Wien, Café Central -- 2018 -- 3135.jpg", "author": "Dietmar Rabich", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Yanaka Ginza 2.jpg", "author": "Christophe95", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Yanaka Ginza shopping street (4041690725).jpg",
|
||||
"author": "Yoshikazu TAKADA from Tokyo, Japan",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{ "file": "Yanaka ginza street.jpg", "author": "Kentin", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Yanaka Ginza, Tokyo, Japan (6790100548).jpg", "author": "yeowatzup", "license": "CC BY 2.0" },
|
||||
{ "file": "Þingvellir, Almannagjá 01.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Þingvellir, Almannagjá 07.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "関西国際空港全体写真20220811.jpg", "author": "Ankou1192", "license": "CC BY-SA 4.0" }
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useDemoAccountLock } from './useDemoLock';
|
||||
|
||||
beforeEach(() => resetAllStores());
|
||||
|
||||
describe('useDemoAccountLock', () => {
|
||||
it('FE-HOOK-DEMOLOCK-001: locks the demo account on a demo instance', () => {
|
||||
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@trek.app' }) });
|
||||
expect(renderHook(() => useDemoAccountLock()).result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-HOOK-DEMOLOCK-002: knows the address the demo account had before the rename', () => {
|
||||
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@nomad.app' }) });
|
||||
expect(renderHook(() => useDemoAccountLock()).result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-HOOK-DEMOLOCK-003: leaves the admin of a demo instance alone', () => {
|
||||
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'admin@trek.app', role: 'admin' }) });
|
||||
expect(renderHook(() => useDemoAccountLock()).result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-DEMOLOCK-004: leaves the demo address alone on an instance that is not a demo', () => {
|
||||
seedStore(useAuthStore, { demoMode: false, user: buildUser({ email: 'demo@trek.app' }) });
|
||||
expect(renderHook(() => useDemoAccountLock()).result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-DEMOLOCK-005: is off while nobody is signed in', () => {
|
||||
seedStore(useAuthStore, { demoMode: true, user: null });
|
||||
expect(renderHook(() => useDemoAccountLock()).result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { isDemoAccountEmail } from '@trek/shared';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
/**
|
||||
* Whether the account signed in here is the shared demo account of a demo
|
||||
* instance. Every visitor signs in as that one account, so its Account tab,
|
||||
* API keys, MCP configuration, own AI connection and language sit under a
|
||||
* DemoLock; the server refuses the same writes (`demo-write.ts`). The admin
|
||||
* of a demo instance, and every account of a real one, see the sections as
|
||||
* they are.
|
||||
*/
|
||||
export function useDemoAccountLock(): boolean {
|
||||
const demoMode = useAuthStore((s) => s.demoMode);
|
||||
const email = useAuthStore((s) => s.user?.email);
|
||||
return demoMode && isDemoAccountEmail(email);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { formatCountdown, secondsUntilReset } from './demoWelcomeModel';
|
||||
|
||||
/**
|
||||
* Whether the photo credits have the dialog: not yet, now, or no longer
|
||||
* (the visitor came back from them, and the focus returns to their link).
|
||||
*/
|
||||
type CreditsView = 'unseen' | 'open' | 'left';
|
||||
|
||||
/**
|
||||
* The welcome dialog's state: whether it is still open, whether it shows the
|
||||
* photo credits in place of the welcome, and the live countdown to the next
|
||||
* hourly reset, ticking once a second in the server's time zone.
|
||||
*/
|
||||
export function useDemoWelcome() {
|
||||
const timeZone = useAuthStore((s) => s.serverTimezone);
|
||||
const [open, setOpen] = useState(true);
|
||||
const [credits, setCredits] = useState<CreditsView>('unseen');
|
||||
const [secondsLeft, setSecondsLeft] = useState(() => secondsUntilReset(new Date(), timeZone));
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const timer = setInterval(() => setSecondsLeft(secondsUntilReset(new Date(), timeZone)), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [open, timeZone]);
|
||||
|
||||
return {
|
||||
open,
|
||||
close: () => setOpen(false),
|
||||
countdown: formatCountdown(secondsLeft),
|
||||
creditsOpen: credits === 'open',
|
||||
backFromCredits: credits === 'left',
|
||||
openCredits: () => setCredits('open'),
|
||||
closeCredits: () => setCredits('left'),
|
||||
};
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
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';
|
||||
|
||||
describe('DemoBanner', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-001
|
||||
it('renders without crashing', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-002
|
||||
it('overlay is visible on initial render with dismiss button', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText('Got it')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-003
|
||||
it('shows English welcome title by default', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText(/Welcome to/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-004
|
||||
it('clicking "Got it" dismisses the banner', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DemoBanner />);
|
||||
const button = screen.getByText('Got it');
|
||||
await user.click(button);
|
||||
expect(screen.queryByText('Got it')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-005
|
||||
it('clicking the overlay backdrop dismisses the banner', () => {
|
||||
const { container } = render(<DemoBanner />);
|
||||
// The outermost fixed div is the overlay backdrop
|
||||
const overlay = container.firstChild as HTMLElement;
|
||||
fireEvent.click(overlay);
|
||||
expect(screen.queryByText('Got it')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-006
|
||||
it('clicking the inner card does NOT dismiss', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DemoBanner />);
|
||||
// The inner card is the direct parent of the "Got it" button's container
|
||||
const card = screen.getByText('Got it').closest('div[style*="background: white"]')!;
|
||||
await user.click(card);
|
||||
expect(screen.getByText('Got it')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-007
|
||||
it('shows reset timer', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText(/Next reset in/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-008
|
||||
it('shows upload-disabled notice', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText(/File uploads.*disabled in demo/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-009
|
||||
it('shows "What is TREK?" section', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText('What is TREK?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-010
|
||||
it('shows addon cards', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText('Vacay')).toBeInTheDocument();
|
||||
expect(screen.getByText('Atlas')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-011
|
||||
it('shows full version features section', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText(/Additionally in the full version/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-012
|
||||
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/liketrek/TREK');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
// Timer update test
|
||||
it('updates countdown timer after interval tick', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: false });
|
||||
// Set time to XX:30 so minutesLeft = 59 - 30 = 29
|
||||
vi.setSystemTime(new Date(2026, 3, 7, 12, 30, 0));
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText(/29 minutes/)).toBeInTheDocument();
|
||||
|
||||
// Advance to XX:31 and tick the interval; wrap in act so React flushes state update
|
||||
await act(async () => {
|
||||
vi.setSystemTime(new Date(2026, 3, 7, 12, 31, 0));
|
||||
vi.advanceTimersByTime(10000);
|
||||
});
|
||||
expect(screen.getByText(/28 minutes/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,613 +0,0 @@
|
||||
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;
|
||||
}
|
||||
|
||||
const texts: Record<string, DemoTexts> = {
|
||||
de: {
|
||||
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.',
|
||||
resetIn: 'Naechster Reset in',
|
||||
minutes: 'Minuten',
|
||||
uploadNote: 'Datei-Uploads (Fotos, Dokumente, Cover) sind in der Demo deaktiviert.',
|
||||
fullVersionTitle: 'In der Vollversion zusaetzlich:',
|
||||
features: [
|
||||
'Datei-Uploads (Fotos, Dokumente, Cover)',
|
||||
'API-Schluessel (Google Maps, Wetter)',
|
||||
'Benutzer- & Rechteverwaltung',
|
||||
'Automatische Backups',
|
||||
'Addon-Verwaltung (aktivieren/deaktivieren)',
|
||||
'OIDC / SSO Single Sign-On',
|
||||
],
|
||||
addonsTitle: 'Modulare Addons (in der Vollversion deaktivierbar)',
|
||||
addons: [
|
||||
['Vacay', 'Urlaubsplaner mit Kalender, Feiertagen & Fusion'],
|
||||
['Atlas', 'Weltkarte mit besuchten Laendern & Reisestatistiken'],
|
||||
['Packliste', 'Checklisten pro Reise'],
|
||||
['Budget', 'Kostenplanung mit Splitting'],
|
||||
['Dokumente', 'Dateien an Reisen anhaengen'],
|
||||
['Widgets', 'Waehrungsrechner & Zeitzonen'],
|
||||
],
|
||||
whatIs: 'Was ist TREK?',
|
||||
whatIsDesc:
|
||||
'Ein selbst-gehosteter Reiseplaner mit Echtzeit-Kollaboration, interaktiver Karte, OIDC Login und Dark Mode.',
|
||||
selfHost: 'Open Source — ',
|
||||
selfHostLink: 'selbst hosten',
|
||||
close: 'Verstanden',
|
||||
},
|
||||
en: {
|
||||
titleBefore: 'Welcome to ',
|
||||
titleAfter: '',
|
||||
title: 'Welcome to the TREK Demo',
|
||||
description: 'You can view, edit and create trips. All changes are automatically reset every hour.',
|
||||
resetIn: 'Next reset in',
|
||||
minutes: 'minutes',
|
||||
uploadNote: 'File uploads (photos, documents, covers) are disabled in demo mode.',
|
||||
fullVersionTitle: 'Additionally in the full version:',
|
||||
features: [
|
||||
'File uploads (photos, documents, covers)',
|
||||
'API key management (Google Maps, Weather)',
|
||||
'User & permission management',
|
||||
'Automatic backups',
|
||||
'Addon management (enable/disable)',
|
||||
'OIDC / SSO single sign-on',
|
||||
],
|
||||
addonsTitle: 'Modular Addons (can be deactivated in full version)',
|
||||
addons: [
|
||||
['Vacay', 'Vacation planner with calendar, holidays & user fusion'],
|
||||
['Atlas', 'World map with visited countries & travel stats'],
|
||||
['Packing', 'Checklists per trip'],
|
||||
['Budget', 'Expense tracking with splitting'],
|
||||
['Documents', 'Attach files to trips'],
|
||||
['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.',
|
||||
selfHost: 'Open source — ',
|
||||
selfHostLink: 'self-host it',
|
||||
close: 'Got it',
|
||||
},
|
||||
et: {
|
||||
titleBefore: 'Tere tulemast ',
|
||||
titleAfter: 'i',
|
||||
title: 'Tere tulemast TREKi demosse',
|
||||
description: 'Saad reise vaadata, muuta ja luua. Kõik muudatused lähtestatakse automaatselt iga tunni järel.',
|
||||
resetIn: 'Järgmise lähtestamiseni',
|
||||
minutes: 'minutit',
|
||||
uploadNote: 'Failide (fotode, dokumentide ja kaanepiltide) üleslaadimine on demorežiimis keelatud.',
|
||||
fullVersionTitle: 'Täisversioonis lisaks:',
|
||||
features: [
|
||||
'Failide üleslaadimine (fotod, dokumendid, kaanepildid)',
|
||||
'API-võtmete haldus (Google Maps, ilm)',
|
||||
'Kasutajate ja õiguste haldus',
|
||||
'Automaatsed varukoopiad',
|
||||
'Lisamoodulite haldus (lubamine/keelamine)',
|
||||
'OIDC / SSO ühekordne sisselogimine',
|
||||
],
|
||||
addonsTitle: 'Lisamoodulid (täisversioonis saab välja lülitada)',
|
||||
addons: [
|
||||
['Vacay', 'Puhkuseplaneerija kalendri, pühade ja kasutajate plaanide ühendamisega'],
|
||||
['Atlas', 'Maailmakaart külastatud riikide ja reisistatistikaga'],
|
||||
['Pakkimine', 'Iga reisi kontrollnimekirjad'],
|
||||
['Eelarve', 'Kulude jälgimine ja jagamine'],
|
||||
['Dokumendid', 'Lisa reisidele faile'],
|
||||
['Vidinad', 'Valuutakalkulaator ja ajavööndid'],
|
||||
],
|
||||
whatIs: 'Mis on TREK?',
|
||||
whatIsDesc:
|
||||
'Ise majutatav reisiplaneerija reaalajas koostöö, interaktiivsete kaartide, OIDC sisselogimise ja tumeda režiimiga.',
|
||||
selfHost: 'Avatud lähtekood — ',
|
||||
selfHostLink: 'majuta ise',
|
||||
close: 'Selge',
|
||||
},
|
||||
es: {
|
||||
titleBefore: 'Bienvenido a ',
|
||||
titleAfter: '',
|
||||
title: 'Bienvenido a la demo de TREK',
|
||||
description: 'Puedes ver, editar y crear viajes. Todos los cambios se restablecen automáticamente cada hora.',
|
||||
resetIn: 'Próximo reinicio en',
|
||||
minutes: 'minutos',
|
||||
uploadNote: 'Las subidas de archivos (fotos, documentos, portadas) están desactivadas en el modo demo.',
|
||||
fullVersionTitle: 'Además, en la versión completa:',
|
||||
features: [
|
||||
'Subida de archivos (fotos, documentos, portadas)',
|
||||
'Gestión de claves API (Google Maps, tiempo)',
|
||||
'Gestión de usuarios y permisos',
|
||||
'Copias de seguridad automáticas',
|
||||
'Gestión de addons (activar/desactivar)',
|
||||
'Inicio de sesión único OIDC / SSO',
|
||||
],
|
||||
addonsTitle: 'Complementos modulares (se pueden desactivar en la versión completa)',
|
||||
addons: [
|
||||
['Vacaciones', 'Planificador de vacaciones con calendario, festivos y fusión de usuarios'],
|
||||
['Atlas', 'Mapa del mundo con países visitados y estadísticas de viaje'],
|
||||
['Equipaje', 'Listas de comprobación para cada viaje'],
|
||||
['Presupuesto', 'Control de gastos con reparto'],
|
||||
['Documentos', 'Adjunta archivos a los viajes'],
|
||||
['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.',
|
||||
selfHost: 'Código abierto — ',
|
||||
selfHostLink: 'alójalo tú mismo',
|
||||
close: 'Entendido',
|
||||
},
|
||||
zh: {
|
||||
titleBefore: '欢迎来到 ',
|
||||
titleAfter: '',
|
||||
title: '欢迎来到 TREK 演示版',
|
||||
description: '你可以查看、编辑和创建旅行。所有更改都会在每小时自动重置。',
|
||||
resetIn: '下次重置将在',
|
||||
minutes: '分钟后',
|
||||
uploadNote: '演示模式下已禁用文件上传(照片、文档、封面)。',
|
||||
fullVersionTitle: '完整版本还包括:',
|
||||
features: [
|
||||
'文件上传(照片、文档、封面)',
|
||||
'API 密钥管理(Google Maps、天气)',
|
||||
'用户和权限管理',
|
||||
'自动备份',
|
||||
'附加组件管理(启用/禁用)',
|
||||
'OIDC / SSO 单点登录',
|
||||
],
|
||||
addonsTitle: '模块化附加组件(完整版本可禁用)',
|
||||
addons: [
|
||||
['Vacay', '带日历、节假日和用户融合的假期规划器'],
|
||||
['Atlas', '带已访问国家和旅行统计的世界地图'],
|
||||
['Packing', '按旅行管理清单'],
|
||||
['Budget', '支持分摊的费用追踪'],
|
||||
['Documents', '将文件附加到旅行'],
|
||||
['Widgets', '货币换算和时区工具'],
|
||||
],
|
||||
whatIs: '什么是 TREK?',
|
||||
whatIsDesc: '一个支持实时协作、交互式地图、OIDC 登录和深色模式的自托管旅行规划器。',
|
||||
selfHost: '开源项目 - ',
|
||||
selfHostLink: '自行部署',
|
||||
close: '知道了',
|
||||
},
|
||||
'zh-TW': {
|
||||
titleBefore: '歡迎來到 ',
|
||||
titleAfter: '',
|
||||
title: '歡迎來到 TREK 展示版',
|
||||
description: '你可以檢視、編輯和建立行程。所有變更都會在每小時自動重設。',
|
||||
resetIn: '下次重設將在',
|
||||
minutes: '分鐘後',
|
||||
uploadNote: '展示模式下已停用檔案上傳(照片、文件、封面)。',
|
||||
fullVersionTitle: '完整版本還包含:',
|
||||
features: [
|
||||
'檔案上傳(照片、文件、封面)',
|
||||
'API 金鑰管理(Google Maps、天氣)',
|
||||
'使用者與權限管理',
|
||||
'自動備份',
|
||||
'附加元件管理(啟用/停用)',
|
||||
'OIDC / SSO 單一登入',
|
||||
],
|
||||
addonsTitle: '模組化附加元件(完整版本可停用)',
|
||||
addons: [
|
||||
['Vacay', '具備日曆、假日與使用者融合的假期規劃器'],
|
||||
['Atlas', '顯示已造訪國家與旅行統計的世界地圖'],
|
||||
['Packing', '依行程管理的檢查清單'],
|
||||
['Budget', '支援分攤的費用追蹤'],
|
||||
['Documents', '將檔案附加到行程'],
|
||||
['Widgets', '貨幣換算與時區工具'],
|
||||
],
|
||||
whatIs: 'TREK 是什麼?',
|
||||
whatIsDesc: '一個支援即時協作、互動式地圖、OIDC 登入和深色模式的自架旅行規劃器。',
|
||||
selfHost: '開源專案 - ',
|
||||
selfHostLink: '自行架設',
|
||||
close: '知道了',
|
||||
},
|
||||
ar: {
|
||||
titleBefore: 'مرحبًا بك في ',
|
||||
titleAfter: '',
|
||||
title: 'مرحبًا بك في النسخة التجريبية من TREK',
|
||||
description: 'يمكنك عرض الرحلات وتعديلها وإنشاء رحلات جديدة. تتم إعادة ضبط جميع التغييرات تلقائيًا كل ساعة.',
|
||||
resetIn: 'إعادة الضبط التالية خلال',
|
||||
minutes: 'دقيقة',
|
||||
uploadNote: 'رفع الملفات (الصور والمستندات وصور الغلاف) معطّل في وضع العرض التجريبي.',
|
||||
fullVersionTitle: 'وفي النسخة الكاملة أيضًا:',
|
||||
features: [
|
||||
'رفع الملفات (الصور والمستندات وصور الغلاف)',
|
||||
'إدارة مفاتيح API (خرائط Google والطقس)',
|
||||
'إدارة المستخدمين والصلاحيات',
|
||||
'نسخ احتياطية تلقائية',
|
||||
'إدارة الإضافات (تفعيل/تعطيل)',
|
||||
'تسجيل دخول موحد OIDC / SSO',
|
||||
],
|
||||
addonsTitle: 'إضافات مرنة (يمكن تعطيلها في النسخة الكاملة)',
|
||||
addons: [
|
||||
['Vacay', 'مخطط إجازات مع تقويم وعطل ودمج مستخدمين'],
|
||||
['Atlas', 'خريطة عالمية مع الدول التي تمت زيارتها وإحصاءات السفر'],
|
||||
['Packing', 'قوائم تجهيز لكل رحلة'],
|
||||
['Budget', 'تتبع المصروفات مع التقسيم'],
|
||||
['Documents', 'إرفاق الملفات بالرحلات'],
|
||||
['Widgets', 'محول عملات ومناطق زمنية'],
|
||||
],
|
||||
whatIs: 'ما هو TREK؟',
|
||||
whatIsDesc: 'مخطط رحلات مستضاف ذاتيًا مع تعاون لحظي وخرائط تفاعلية وتسجيل دخول OIDC ووضع داكن.',
|
||||
selfHost: 'مفتوح المصدر — ',
|
||||
selfHostLink: 'استضفه بنفسك',
|
||||
close: 'فهمت',
|
||||
},
|
||||
id: {
|
||||
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.',
|
||||
resetIn: 'Atur ulang berikutnya dalam',
|
||||
minutes: 'menit',
|
||||
uploadNote: 'Unggah file (foto, dokumen, sampul) dinonaktifkan dalam mode demo.',
|
||||
fullVersionTitle: 'Selain itu dalam versi lengkap:',
|
||||
features: [
|
||||
'Unggah file (foto, dokumen, sampul)',
|
||||
'Manajemen kunci API (Google Maps, Cuaca)',
|
||||
'Manajemen pengguna & izin',
|
||||
'Pencadangan otomatis',
|
||||
'Manajemen Addon (aktifkan/nonaktifkan)',
|
||||
'OIDC / SSO single sign-on',
|
||||
],
|
||||
addonsTitle: 'Addon Modular (dapat dinonaktifkan di versi lengkap)',
|
||||
addons: [
|
||||
['Vacay', 'Perencana liburan dengan kalender, hari libur & penggabungan pengguna'],
|
||||
['Atlas', 'Peta dunia dengan negara yang dikunjungi & statistik perjalanan'],
|
||||
['Pengepakan', 'Daftar periksa per perjalanan'],
|
||||
['Anggaran', 'Pelacakan pengeluaran dengan pemisahan tagihan'],
|
||||
['Dokumen', 'Lampirkan file ke perjalanan'],
|
||||
['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.',
|
||||
selfHost: 'Buka sumber — ',
|
||||
selfHostLink: 'host mandiri',
|
||||
close: 'Mengerti',
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setMinutesLeft(59 - new Date().getMinutes()), 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
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))',
|
||||
paddingInline: 16,
|
||||
overflow: 'auto',
|
||||
fontFamily: 'var(--font-system)',
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t.close}
|
||||
onClick={() => setDismissed(true)}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setDismissed(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
<Upload size={13} style={{ flexShrink: 0, color: '#b45309' }} />
|
||||
<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={{ 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,
|
||||
}}
|
||||
>
|
||||
{t.whatIs}
|
||||
</span>
|
||||
</div>
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
<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];
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
color: '#94a3b8',
|
||||
margin: 0,
|
||||
lineHeight: 1.3,
|
||||
paddingInlineStart: 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,
|
||||
}}
|
||||
>
|
||||
<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];
|
||||
return (
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
<Github size={13} />
|
||||
<span>{t.selfHost}</span>
|
||||
<a
|
||||
href="https://github.com/liketrek/TREK"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#111827', fontWeight: 600, textDecoration: 'none' }}
|
||||
>
|
||||
{t.selfHostLink}
|
||||
</a>
|
||||
</div>
|
||||
<button type="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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import AccountDemoLock from './AccountDemoLock';
|
||||
|
||||
beforeEach(() => resetAllStores());
|
||||
|
||||
function renderCards() {
|
||||
return render(
|
||||
<AccountDemoLock>
|
||||
<button type="button">Save profile</button>
|
||||
</AccountDemoLock>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AccountDemoLock', () => {
|
||||
it('FE-COMP-ACCOUNTDEMOLOCK-001: the shared demo account sees its cards under the lock, with the reason', () => {
|
||||
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@trek.app' }) });
|
||||
const { container } = renderCards();
|
||||
expect(screen.getByRole('note')).toHaveTextContent('The shared demo account cannot be changed.');
|
||||
expect(screen.queryByRole('button', { name: 'Save profile' })).not.toBeInTheDocument();
|
||||
expect(container.querySelector('[data-demo-lock] [inert]')).toContainElement(
|
||||
screen.getByRole('button', { hidden: true, name: 'Save profile' })
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNTDEMOLOCK-002: the admin of a demo instance gets the cards as they are', () => {
|
||||
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'admin@trek.app', role: 'admin' }) });
|
||||
const { container } = renderCards();
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Save profile' })).toBeEnabled();
|
||||
expect(container.querySelector('[data-demo-lock]')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNTDEMOLOCK-003: the demo address on an instance that is not a demo is left alone', () => {
|
||||
seedStore(useAuthStore, { demoMode: false, user: buildUser({ email: 'demo@trek.app' }) });
|
||||
renderCards();
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Save profile' })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import DemoLock from '../Demo/DemoLock';
|
||||
import { useDemoAccountLock } from '../Demo/useDemoLock';
|
||||
|
||||
/**
|
||||
* The account cards under the demo lock, on the desktop tab and the phone
|
||||
* screen alike. Every visitor of a demo instance signs in as the one shared
|
||||
* demo account, so its profile, password, two factor setup and passkeys stay
|
||||
* on view but out of reach, while the admin of a demo instance and every
|
||||
* account of a real one get the cards as they are. Both views wrap their
|
||||
* cards in this, so whom the lock holds and the reason it gives are written
|
||||
* once.
|
||||
*/
|
||||
export default function AccountDemoLock({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const locked = useDemoAccountLock();
|
||||
return (
|
||||
<DemoLock locked={locked} reason={t('demo.lock.account')}>
|
||||
{children}
|
||||
</DemoLock>
|
||||
);
|
||||
}
|
||||
@@ -395,10 +395,20 @@ describe('AccountTab – MFA', () => {
|
||||
expect(screen.getByText(/requires two-factor authentication/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNT-036: MFA section shows demoBlocked message in demo mode', () => {
|
||||
seedStore(useAuthStore, { demoMode: true });
|
||||
it('FE-COMP-ACCOUNT-036: the shared demo account sees the whole tab under the demo lock', () => {
|
||||
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@trek.app', mfa_enabled: false }) });
|
||||
const { container } = render(<AccountTab />);
|
||||
expect(screen.getByRole('note')).toHaveTextContent('The shared demo account cannot be changed.');
|
||||
expect(container.querySelector('[data-demo-lock] [inert]')).toContainElement(
|
||||
screen.getByRole('button', { hidden: true, name: /Set up authenticator/ }),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNT-036b: the admin of a demo instance keeps the tab unlocked, 2FA included', () => {
|
||||
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'admin@trek.app', role: 'admin', mfa_enabled: false }) });
|
||||
render(<AccountTab />);
|
||||
expect(screen.getByText('Not available in demo mode')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Set up authenticator/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill,
|
||||
} from './settingsKit'
|
||||
import { stripTrailingSlashes, useAccountSettings } from './useAccountSettings'
|
||||
import AccountDemoLock from './AccountDemoLock'
|
||||
|
||||
/** The small turning ring a button shows while its request is on the way. */
|
||||
const SPINNER = 'h-3.5 w-3.5 flex-none animate-spin rounded-full border-2 border-current border-t-transparent'
|
||||
@@ -71,6 +72,8 @@ export default function AccountTab(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The shared demo account: everything here is shown, nothing can be changed. */}
|
||||
<AccountDemoLock>
|
||||
<Section title={t('settings.account')} icon={User}>
|
||||
{/* Who is signed in: the picture with its two actions, the role and the SSO link. */}
|
||||
<div className="flex items-center gap-4 rounded-[14px] border border-edge-faint bg-surface-card px-4 py-3.5">
|
||||
@@ -221,10 +224,7 @@ export default function AccountTab(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
<SettingsHint>{t('settings.mfa.description')}</SettingsHint>
|
||||
{demoMode ? (
|
||||
<p className="m-0 font-medium text-warning" style={fs(12.5, 'body')}>{t('settings.mfa.demoBlocked')}</p>
|
||||
) : (
|
||||
<>
|
||||
<>
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
|
||||
@@ -357,12 +357,12 @@ export default function AccountTab(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</Section>
|
||||
|
||||
{/* Passkeys */}
|
||||
<PasskeysSection demoMode={demoMode} />
|
||||
</AccountDemoLock>
|
||||
|
||||
{/* Delete Account Blocked */}
|
||||
<DialogShell
|
||||
|
||||
@@ -483,3 +483,18 @@ describe('DisplaySettingsTab week start (#2029)', () => {
|
||||
expect(await screen.findByText('Save failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DisplaySettingsTab – the demo account', () => {
|
||||
it('FE-COMP-DISPLAY-DEMO-001: the shared demo account sees English under the demo lock', () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }), isLoaded: true });
|
||||
render(<DisplaySettingsTab />);
|
||||
expect(screen.getByRole('note')).toHaveTextContent('The demo always runs in English.');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-DEMO-002: any other account of a demo instance picks its language freely', () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ email: 'admin@trek.app' }), isAuthenticated: true, demoMode: true });
|
||||
render(<DisplaySettingsTab />);
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,8 @@ import { DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from '../../utils/startDes
|
||||
import type { DistanceUnit } from '../../types'
|
||||
import { DEFAULT_WEEK_START } from '@trek/shared'
|
||||
import { weekStartOptions } from '../../utils/calendarWeek'
|
||||
import DemoLock from '../Demo/DemoLock'
|
||||
import { useDemoAccountLock } from '../Demo/useDemoLock'
|
||||
|
||||
/** A select on the right of a row: wide enough for a currency or a weekday, never wider than the row. */
|
||||
const SELECT_BOX = 'w-[260px] max-w-full'
|
||||
@@ -25,6 +27,7 @@ const SELECT_BOX = 'w-[260px] max-w-full'
|
||||
export default function DisplaySettingsTab(): React.ReactElement {
|
||||
const { settings, save } = useSettingSaver()
|
||||
const { t, locale } = useTranslation()
|
||||
const demoLocked = useDemoAccountLock()
|
||||
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || DEFAULT_SETTINGS.temperature_unit)
|
||||
const [distanceUnit, setDistanceUnit] = useState<DistanceUnit>(settings.distance_unit || DEFAULT_SETTINGS.distance_unit)
|
||||
const [langOpen, setLangOpen] = useState(false)
|
||||
@@ -123,7 +126,9 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Language: every option at a glance on a wide window, a compact list on a narrow one */}
|
||||
{/* Language: every option at a glance on a wide window, a compact list on a narrow one.
|
||||
The shared demo account always runs in English. */}
|
||||
<DemoLock locked={demoLocked} reason={t('demo.lock.language')} size="row">
|
||||
<SettingRow label={t('settings.language')} stacked>
|
||||
<div className="hidden sm:block">
|
||||
<ChoiceChips
|
||||
@@ -162,6 +167,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
</DemoLock>
|
||||
|
||||
{/* Place names (#1799): searches can answer in another language than the app. */}
|
||||
<SettingRow
|
||||
|
||||
@@ -18,10 +18,13 @@ import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, Setting
|
||||
import { OAUTH_PRESETS, useMcpIntegration, type OAuthClient } from './useMcpIntegration'
|
||||
import { useIntegrationGates } from './useIntegrationGates'
|
||||
import WandererConnectionSection from './WandererConnectionSection'
|
||||
import DemoLock from '../Demo/DemoLock'
|
||||
import { useDemoAccountLock } from '../Demo/useDemoLock'
|
||||
|
||||
export default function IntegrationsTab(): React.ReactElement {
|
||||
const S = useIntegrations()
|
||||
const managed = S.managed
|
||||
const demoLocked = useDemoAccountLock()
|
||||
return (
|
||||
<>
|
||||
{/* Immich, Synology Photos and AirTrail all connect to a server the reader
|
||||
@@ -42,9 +45,15 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
{S.llmEnabled && S.ownParsing && !managed && <LlmConnectionSection />}
|
||||
{/* Above MCP on purpose: an API key needs no addon, and someone looking for
|
||||
one should not have to read past a section about AI assistants. */}
|
||||
<ApiKeysSection />
|
||||
<DemoLock locked={demoLocked} reason={S.t('demo.lock.apiKeys')}>
|
||||
<ApiKeysSection />
|
||||
</DemoLock>
|
||||
{/* TREK inside Claude rides on the MCP server, so it follows that addon. */}
|
||||
{S.mcpEnabled && <IntegrationsMcpSection S={S} />}
|
||||
{S.mcpEnabled && (
|
||||
<DemoLock locked={demoLocked} reason={S.t('demo.lock.mcp')}>
|
||||
<IntegrationsMcpSection S={S} />
|
||||
</DemoLock>
|
||||
)}
|
||||
<McpTokenModals S={S} />
|
||||
<OAuthClientModals S={S} />
|
||||
</>
|
||||
|
||||
@@ -140,4 +140,13 @@ describe('AiConnectionSection', () => {
|
||||
const { container } = render(<AiConnectionSection />);
|
||||
await waitFor(() => expect(container).toBeEmptyDOMElement());
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-CONNECTION-008: the shared demo account sees its own connection locked, and no admin hint', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
|
||||
repo.status.mockResolvedValue(status({ canConnectOwn: false }));
|
||||
render(<AiConnectionSection />);
|
||||
expect(await screen.findByRole('note')).toHaveTextContent('Your own AI connection cannot be added in the demo.');
|
||||
expect(screen.queryByText('Your admin sets this up.')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { hidden: true, name: 'Connect your own provider' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Loader2, LogOut, MessageCircle, Sparkles, Unplug } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useAssistantCostEstimates, useAssistantMapFollow } from '../../Assistant/useAssistantMapFollow';
|
||||
import DemoLock from '../../Demo/DemoLock';
|
||||
import { useDemoAccountLock } from '../../Demo/useDemoLock';
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog';
|
||||
import CustomSelect from '../../shared/CustomSelect';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
@@ -202,6 +204,25 @@ function AssistantBehaviour() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Where the shared demo account would connect a provider of its own: shown, and locked. */
|
||||
function LockedOwnConnection() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DemoLock locked reason={t('demo.lock.ai')} size="row">
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={t('demo.lock.aiTitle')}
|
||||
control={
|
||||
<button type="button" disabled className={SETTINGS_BUTTON} style={fs(13, 'body')}>
|
||||
{t('assistant.errorAction.connectOwn')}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</SettingRows>
|
||||
</DemoLock>
|
||||
);
|
||||
}
|
||||
|
||||
/** The connect panel, opened on purpose or because nothing is connected yet. */
|
||||
function ConnectPanel({ S }: { S: AiConnections }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -254,6 +275,7 @@ export default function AiConnectionSection({
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const S = useAiConnections();
|
||||
const demoLocked = useDemoAccountLock();
|
||||
const text = aiSectionText(assistant, parsing);
|
||||
if (S.view === 'hidden') return null;
|
||||
const own = S.own;
|
||||
@@ -277,7 +299,8 @@ export default function AiConnectionSection({
|
||||
{S.view === 'own' && own && <OwnConnection S={S} own={own} />}
|
||||
{S.view === 'instance' && <InstanceConnection S={S} />}
|
||||
{assistant && (S.view === 'own' || S.view === 'instance') && <AssistantBehaviour />}
|
||||
{S.view === 'adminOnly' && <SettingsHint>{t('assistant.setup.adminOnly')}</SettingsHint>}
|
||||
{S.view === 'adminOnly' && !demoLocked && <SettingsHint>{t('assistant.setup.adminOnly')}</SettingsHint>}
|
||||
{demoLocked && S.view !== 'loading' && <LockedOwnConnection />}
|
||||
|
||||
{S.panel && <ConnectPanel S={S} />}
|
||||
<SectionFooter S={S} />
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useInAppNotificationStore } from '../../../store/inAppNotificationStore
|
||||
import { useDashboardPlugins } from '../../../components/Dashboard/useDashboardPlugins'
|
||||
import { useAssistantHomeLauncher } from '../../../components/Assistant/useAssistantHome'
|
||||
import type { TripCardBadge } from '../../../api/client'
|
||||
import DemoBanner from '../../../components/Layout/DemoBanner'
|
||||
import DemoWelcome from '../../../components/Demo/DemoWelcome'
|
||||
import { IcsSubscribeModal } from '../../../components/Planner/IcsSubscribeModal'
|
||||
import PluginWidgets from '../../../components/Plugins/PluginWidgets'
|
||||
import { entityGradient } from '../../../utils/gradients'
|
||||
@@ -247,7 +247,7 @@ export default function MDashboard(): React.ReactElement {
|
||||
|
||||
<MUserMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
|
||||
|
||||
{demoMode && <DemoBanner />}
|
||||
{demoMode && <DemoWelcome />}
|
||||
|
||||
<div className="px-4 pb-[calc(var(--bottom-nav-h,84px)+32px)] pt-[calc(var(--m-safe-top,12px)+82px)]">
|
||||
{loadError && (
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { AiConnectionView, AiStatusResponse } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildUser } from '../../../../tests/helpers/factories';
|
||||
import { render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
import { seedStore } from '../../../../tests/helpers/store';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
status: vi.fn(),
|
||||
@@ -83,4 +86,20 @@ describe('MAiConnectionSection', () => {
|
||||
expect(await screen.findByText('What would you like to chat with?')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Paste an API key/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AI-DEMO-001: the shared demo account sees the place for its own connection locked', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
|
||||
repo.status.mockResolvedValue(status({ canConnectOwn: false }));
|
||||
render(<MAiConnectionSection />);
|
||||
expect(await screen.findByRole('note')).toHaveTextContent('Your own AI connection cannot be added in the demo.');
|
||||
expect(screen.queryByText('Your admin sets this up.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AI-DEMO-002: any other account keeps the admin hint', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ email: 'admin@trek.app' }), isAuthenticated: true, demoMode: true });
|
||||
repo.status.mockResolvedValue(status({ canConnectOwn: false }));
|
||||
render(<MAiConnectionSection />);
|
||||
expect(await screen.findByText('Your admin sets this up.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { AiConnectionView } from '@trek/shared';
|
||||
import { ChevronDown, Loader2, LogOut, MessageCircle, Sparkles, Unplug } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useAssistantCostEstimates, useAssistantMapFollow } from '../../../components/Assistant/useAssistantMapFollow';
|
||||
import DemoLock from '../../../components/Demo/DemoLock';
|
||||
import { useDemoAccountLock } from '../../../components/Demo/useDemoLock';
|
||||
import AiConnectPanel from '../../../components/Settings/ai/AiConnectPanel';
|
||||
import { aiSectionText, CONNECTION_STATUS } from '../../../components/Settings/ai/aiConnectModel';
|
||||
import { useAiConnections } from '../../../components/Settings/ai/useAiConnections';
|
||||
@@ -117,6 +119,30 @@ function MInstanceConnection({ S }: { S: AiConnections }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Under the connection: the hint that only the admin connects one, or, for the
|
||||
* shared demo account, the locked place where its own would go.
|
||||
*/
|
||||
function MOwnConnectionNote({ view }: { view: AiConnections['view'] }) {
|
||||
const { t } = useTranslation();
|
||||
const demoLocked = useDemoAccountLock();
|
||||
if (view === 'loading') return null;
|
||||
if (!demoLocked) return view === 'adminOnly' ? <MSetHint>{t('assistant.setup.adminOnly')}</MSetHint> : null;
|
||||
return (
|
||||
<DemoLock locked reason={t('demo.lock.ai')} size="row" className="mt-3">
|
||||
<MSetRow
|
||||
first
|
||||
label={t('demo.lock.aiTitle')}
|
||||
trailing={
|
||||
<MSetButton variant="ghost" disabled>
|
||||
{t('assistant.errorAction.connectOwn')}
|
||||
</MSetButton>
|
||||
}
|
||||
/>
|
||||
</DemoLock>
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the trip map follows the assistant, the phone's row for the shared switch. */
|
||||
function MAssistantBehaviour() {
|
||||
const { t } = useTranslation();
|
||||
@@ -180,7 +206,7 @@ export default function MAiConnectionSection({
|
||||
{S.view === 'own' && S.own && <MOwnConnection S={S} own={S.own} />}
|
||||
{S.view === 'instance' && <MInstanceConnection S={S} />}
|
||||
{assistant && (S.view === 'own' || S.view === 'instance') && <MAssistantBehaviour />}
|
||||
{S.view === 'adminOnly' && <MSetHint>{t('assistant.setup.adminOnly')}</MSetHint>}
|
||||
<MOwnConnectionNote view={S.view} />
|
||||
|
||||
{S.panel && (
|
||||
<div
|
||||
|
||||
@@ -22,6 +22,7 @@ import MConfirmSheet from './MConfirmSheet'
|
||||
import PasswordChecklist from '../../../components/shared/PasswordChecklist'
|
||||
import { fmtDate, usePasskeys } from '../../../components/Settings/usePasskeys'
|
||||
import { stripTrailingSlashes, useAccountSettings } from '../../../components/Settings/useAccountSettings'
|
||||
import AccountDemoLock from '../../../components/Settings/AccountDemoLock'
|
||||
|
||||
/**
|
||||
* "Account" section — AccountTab parity: profile + avatar, password change,
|
||||
@@ -78,6 +79,8 @@ export default function MSettingsAccount() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The shared demo account: everything here is shown, nothing can be changed. */}
|
||||
<AccountDemoLock>
|
||||
{/* ── Profile ─────────────────────────────────────────────── */}
|
||||
<MSetCard title={t('settings.account')} icon={User}>
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
@@ -185,10 +188,7 @@ export default function MSettingsAccount() {
|
||||
)}
|
||||
<p className="text-[0.75rem] leading-relaxed text-m-muted">{t('settings.mfa.description')}</p>
|
||||
|
||||
{demoMode ? (
|
||||
<p className="mt-2 text-[0.75rem] font-semibold text-[color:var(--m-st-pending)]">{t('settings.mfa.demoBlocked')}</p>
|
||||
) : (
|
||||
<>
|
||||
<>
|
||||
<p className="mt-2 text-[0.78125rem] font-bold text-m-ink">
|
||||
{user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
|
||||
</p>
|
||||
@@ -287,11 +287,11 @@ export default function MSettingsAccount() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</MSetCard>
|
||||
|
||||
<MPasskeysCard demoMode={demoMode} />
|
||||
</AccountDemoLock>
|
||||
|
||||
<MConfirmSheet
|
||||
open={showDeleteConfirm === 'blocked'}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { MSetCard, MSetEyebrow, MSetHint, MSetSelectRow, MSetSegments, MSetRow }
|
||||
import MToggle from '../../components/MToggle'
|
||||
import MSetPickerSheet from './MSetPickerSheet'
|
||||
import { preferredNavAppOptions } from '../../../components/Planner/placeNavigation'
|
||||
import DemoLock from '../../../components/Demo/DemoLock'
|
||||
import { useDemoAccountLock } from '../../../components/Demo/useDemoLock'
|
||||
|
||||
/**
|
||||
* "General" settings section — the demo's Language & region and Travel & map
|
||||
@@ -20,6 +22,7 @@ import { preferredNavAppOptions } from '../../../components/Planner/placeNavigat
|
||||
export default function MSettingsGeneral() {
|
||||
const { t, locale } = useTranslation()
|
||||
const { settings, save } = useSettingSaver()
|
||||
const demoLocked = useDemoAccountLock()
|
||||
const [currencyOpen, setCurrencyOpen] = useState(false)
|
||||
const [langOpen, setLangOpen] = useState(false)
|
||||
const [startTabOpen, setStartTabOpen] = useState(false)
|
||||
@@ -121,8 +124,11 @@ export default function MSettingsGeneral() {
|
||||
<MSetEyebrow className="mb-[5px]">{t('settings.currency')}</MSetEyebrow>
|
||||
<MSetSelectRow label={currencyLabel} trailing={chevron} onClick={() => setCurrencyOpen(true)} />
|
||||
|
||||
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.language')}</MSetEyebrow>
|
||||
<MSetSelectRow label={language.label} trailing={chevron} onClick={() => setLangOpen(true)} />
|
||||
{/* The shared demo account always runs in English. */}
|
||||
<DemoLock locked={demoLocked} reason={t('demo.lock.language')} size="row">
|
||||
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.language')}</MSetEyebrow>
|
||||
<MSetSelectRow label={language.label} trailing={chevron} onClick={() => setLangOpen(true)} />
|
||||
</DemoLock>
|
||||
|
||||
<MSetEyebrow className="mb-[6px] mt-[14px]">{t('settings.temperature')}</MSetEyebrow>
|
||||
<MSetSegments
|
||||
|
||||
@@ -8,6 +8,8 @@ import MChip from '../../components/MChip'
|
||||
import { MSetCard, MSetEyebrow, MSetInput, MSetTextarea, MSetButton, MSetHint, MSetRow } from './MSettingsUi'
|
||||
import MConfirmSheet from './MConfirmSheet'
|
||||
import MSegmented from '../../components/MSegmented'
|
||||
import DemoLock from '../../../components/Demo/DemoLock'
|
||||
import { useDemoAccountLock } from '../../../components/Demo/useDemoLock'
|
||||
|
||||
function CopyButton({ text, copied, onCopy }: { text: string; copied: boolean; onCopy: (text: string) => void }) {
|
||||
return (
|
||||
@@ -37,6 +39,7 @@ function CodeBlock({ children }: { children: string }) {
|
||||
*/
|
||||
export default function MSettingsMcp() {
|
||||
const { t, locale } = useTranslation()
|
||||
const demoLocked = useDemoAccountLock()
|
||||
const {
|
||||
activeMcpTab: activeTab,
|
||||
setActiveMcpTab: setActiveTab,
|
||||
@@ -119,6 +122,7 @@ export default function MSettingsMcp() {
|
||||
)
|
||||
|
||||
return (
|
||||
<DemoLock locked={demoLocked} reason={t('demo.lock.mcp')}>
|
||||
<MSetCard title={t('settings.mcp.title')} icon={Terminal} className="mt-3">
|
||||
<MSetEyebrow className="mb-[5px]">{t('settings.mcp.endpoint')}</MSetEyebrow>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -518,5 +522,6 @@ export default function MSettingsMcp() {
|
||||
onConfirm={() => tokenDeleteId !== null && deleteToken(tokenDeleteId)}
|
||||
/>
|
||||
</MSetCard>
|
||||
</DemoLock>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from '../i18n'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import DemoBanner from '../components/Layout/DemoBanner'
|
||||
import DemoWelcome from '../components/Demo/DemoWelcome'
|
||||
import TripFormModal from '../components/Trips/TripFormModal'
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
import CopyTripDialog from '../components/shared/CopyTripDialog'
|
||||
@@ -138,7 +138,7 @@ function DashboardPageDesktop(): React.ReactElement {
|
||||
<Navbar />
|
||||
<HelpAnchor id="dashboard" />
|
||||
<div className="trek-dash trek-dash-shell">
|
||||
{demoMode && <DemoBanner />}
|
||||
{demoMode && <DemoWelcome />}
|
||||
<div className="trek-dash-scroll">
|
||||
<MobileTopBar />
|
||||
<main className="page" data-no-sidebar={sidebarVisible ? undefined : 'true'}>
|
||||
|
||||
@@ -173,11 +173,11 @@ describe('MDashboard', () => {
|
||||
expect(screen.queryByText('dashboard.emptyTitle')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-008: demo mode adds the banner', () => {
|
||||
it('FE-MOB-DASH-008: demo mode opens the demo welcome', () => {
|
||||
mocks.dash = buildDash({ demoMode: true });
|
||||
render(<MDashboard />);
|
||||
|
||||
expect(screen.getByText(/demo/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog')).toHaveTextContent('Welcome to TREK');
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-009: unread notifications mark the bell and it opens the list', () => {
|
||||
|
||||
@@ -385,12 +385,13 @@ describe('MSettingsAccount – MFA', () => {
|
||||
await screen.findByText('JBSWY3DPEHPK3PXP');
|
||||
}
|
||||
|
||||
it('FE-MOB-SETACC-023: demo mode replaces the controls with the demo notice', () => {
|
||||
seedStore(useAuthStore, { demoMode: true });
|
||||
it('FE-MOB-SETACC-023: the shared demo account sees the controls under the demo lock', () => {
|
||||
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@trek.app', mfa_enabled: false }) });
|
||||
renderAccount();
|
||||
|
||||
expect(screen.getByText('Not available in demo mode')).toBeInTheDocument();
|
||||
expect(screen.getByRole('note')).toHaveTextContent('The shared demo account cannot be changed.');
|
||||
expect(screen.queryByRole('button', { name: 'Set up authenticator' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { hidden: true, name: 'Set up authenticator' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETACC-024: the ?mfa=required entry shows the policy banner', () => {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { currentTimestampKysely } from '../dialect/kysely-functions';
|
||||
import { Addons } from '../entities/Addons.entity';
|
||||
import { AppSettings } from '../entities/AppSettings.entity';
|
||||
import { DocumentProviders } from '../entities/DocumentProviders.entity';
|
||||
import { PhotoProviders } from '../entities/PhotoProviders.entity';
|
||||
import { Settings } from '../entities/Settings.entity';
|
||||
import { Trips } from '../entities/Trips.entity';
|
||||
import { Users } from '../entities/Users.entity';
|
||||
import type { DB } from '../kysely/db';
|
||||
@@ -36,6 +40,15 @@ export interface DemoInstanceKeyRow {
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
/** The states `applyAddonStates` writes: three shelves and the switches under them in `app_settings`. */
|
||||
export interface DemoAddonStates {
|
||||
/** Each shelf names the rows it switches on and the rows it switches off. */
|
||||
readonly addons: { readonly on: readonly string[]; readonly off: readonly string[] };
|
||||
readonly photoProviders: { readonly on: readonly string[]; readonly off: readonly string[] };
|
||||
readonly documentProviders: { readonly on: readonly string[]; readonly off: readonly string[] };
|
||||
readonly appSettings: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DMS9's 13-column `places` insert tuple — kept as a tuple, not an object,
|
||||
* so `demo-seed.ts`'s three ~30-row example-trip data arrays (Tokyo/Kyoto,
|
||||
@@ -400,4 +413,48 @@ export class DemoRepository {
|
||||
await repo.upsert({ key: row.key, value: row.value }, { onConflictFields: ['key'], onConflictAction: 'merge' });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// The fixed demo set (demo-addon-policy.ts), applied by the seed and the reset
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Writes the demo's addon states in one transaction, so a reset that fails
|
||||
* halfway does not leave half the shelf switched. One UPDATE per shelf and
|
||||
* state (generic inherited `nativeUpdate` over `id IN (...)`, the statement
|
||||
* `setEnabled` issues for one row) and one upsert for all the switches
|
||||
* (`AppSettingsRepository#setValue`'s), however long the lists are. A named
|
||||
* row the database does not hold, like an empty list, is an update that
|
||||
* matches nothing, never an insert: the seeders own which rows exist.
|
||||
*/
|
||||
async applyAddonStates(states: DemoAddonStates): Promise<void> {
|
||||
this.validateRequestContext();
|
||||
const switches = Object.entries(states.appSettings).map(([key, value]) => ({ key, value }));
|
||||
await this.em.transactional(async () => {
|
||||
const addons = this.em.getRepository(Addons);
|
||||
await addons.nativeUpdate({ id: { $in: [...states.addons.on] } }, { enabled: true });
|
||||
await addons.nativeUpdate({ id: { $in: [...states.addons.off] } }, { enabled: false });
|
||||
const photos = this.em.getRepository(PhotoProviders);
|
||||
await photos.nativeUpdate({ id: { $in: [...states.photoProviders.on] } }, { enabled: 1 });
|
||||
await photos.nativeUpdate({ id: { $in: [...states.photoProviders.off] } }, { enabled: 0 });
|
||||
const documents = this.em.getRepository(DocumentProviders);
|
||||
await documents.nativeUpdate({ id: { $in: [...states.documentProviders.on] } }, { enabled: 1 });
|
||||
await documents.nativeUpdate({ id: { $in: [...states.documentProviders.off] } }, { enabled: 0 });
|
||||
await this.em
|
||||
.getRepository(AppSettings)
|
||||
.upsertMany(switches, { onConflictFields: ['key'], onConflictAction: 'merge' });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A user's own settings rows, written as given in one statement (the demo
|
||||
* account's fixed language). The upsert `SettingsRepository#upsertForUser`
|
||||
* issues for one key, for every row at once.
|
||||
*/
|
||||
async setUserSettings(userId: number, rows: readonly { key: string; value: string }[]): Promise<void> {
|
||||
await this.em.getRepository(Settings).upsertMany(
|
||||
rows.map(({ key, value }) => ({ user: userId, key, value })),
|
||||
{ onConflictFields: ['user', 'key'], onConflictAction: 'merge' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { DemoAddonStates, DemoRepository } from '../db/repositories/DemoRepository';
|
||||
|
||||
/**
|
||||
* What a demo instance offers, whatever its admin switched during the hour.
|
||||
*
|
||||
* The demo shows everything TREK can do on its own: every trip, global and
|
||||
* integration addon is on. What stays off is what needs a server of the
|
||||
* visitor's own, which a public demo has no address for and no business
|
||||
* reaching: the document sync providers, the wanderer connection under Tours,
|
||||
* the photo providers under Journey, AirTrail and Dawarich. The AI features
|
||||
* (the assistant, AI parsing) and the MCP server stay off as well: they spend
|
||||
* the instance's AI credit or hand out tokens to a shared account, so they are
|
||||
* a feature of a self-hosted install, not of the public demo. So does the
|
||||
* legacy `memories` row, which nothing reads any more.
|
||||
*
|
||||
* Each list names its rows outright instead of "everything but", so a release
|
||||
* that adds an addon has to decide where it goes: the policy test fails on a
|
||||
* seeded row that is in neither list. The demo seed applies this on every boot
|
||||
* and after every reopen, and the hourly reset applies it once more after the
|
||||
* swap, so the baseline cannot bring back an admin's toggle.
|
||||
*/
|
||||
export const DEMO_ADDON_POLICY = {
|
||||
/** Rows of the `addons` table. */
|
||||
addons: {
|
||||
on: [
|
||||
// Trip addons
|
||||
'packing',
|
||||
'budget',
|
||||
'documents',
|
||||
'collab',
|
||||
'roadtrip',
|
||||
'tours',
|
||||
// Global addons
|
||||
'vacay',
|
||||
'atlas',
|
||||
'journey',
|
||||
'collections',
|
||||
// Integrations TREK runs by itself
|
||||
'naver_list_import',
|
||||
],
|
||||
off: [
|
||||
'airtrail',
|
||||
'dawarich',
|
||||
'mcp',
|
||||
'llm_parsing',
|
||||
'ai_assistant',
|
||||
// The trip Photos addon of old releases. Its migration seeds the row
|
||||
// switched off and nothing removes it; the client reads Photos from the
|
||||
// photo providers (addonStore's isEnabled) and the admin panel keeps the
|
||||
// row out of its trip list, so it switches nothing.
|
||||
'memories',
|
||||
],
|
||||
},
|
||||
/** Rows of `photo_providers`, Journey's shelf. */
|
||||
photoProviders: {
|
||||
on: [],
|
||||
off: ['immich', 'synologyphotos'],
|
||||
},
|
||||
/** Rows of `document_providers`, the Documents addon's shelf. */
|
||||
documentProviders: {
|
||||
on: [],
|
||||
off: ['paperless', 'papra', 'nextcloud', 'opencloud', 'synologydrive'],
|
||||
},
|
||||
/**
|
||||
* The switches that sit under an addon in the admin panel, stored in
|
||||
* `app_settings`. wanderer is the one under Tours that reaches a server of
|
||||
* the visitor's own; the rest are on.
|
||||
*/
|
||||
appSettings: {
|
||||
tours_wanderer_enabled: 'false',
|
||||
tours_hiking_map_enabled: 'true',
|
||||
tours_cycle_map_enabled: 'true',
|
||||
ski_routing_enabled: 'true',
|
||||
bag_tracking_enabled: 'true',
|
||||
collab_chat_enabled: 'true',
|
||||
collab_notes_enabled: 'true',
|
||||
collab_links_enabled: 'true',
|
||||
collab_polls_enabled: 'true',
|
||||
collab_whatsnext_enabled: 'true',
|
||||
},
|
||||
} as const satisfies DemoAddonStates;
|
||||
|
||||
/** Puts the instance's addon states to the demo set, in one transaction. A row the database does not hold is skipped. */
|
||||
export async function applyDemoAddonPolicy(
|
||||
demo: DemoRepository,
|
||||
policy: DemoAddonStates = DEMO_ADDON_POLICY,
|
||||
): Promise<void> {
|
||||
await demo.applyAddonStates(policy);
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
} from '../db/repositories/DemoRepository';
|
||||
import type { CarriedUserSessionRow } from '../db/repositories/UserSessions.repository';
|
||||
import { dbNow } from '../db/types';
|
||||
import { logError } from '../nest/audit/audit-log.logger';
|
||||
import { DatabaseConnectionLostError, type DatabaseBackupStrategy } from '../nest/database/database-backup.interface';
|
||||
import { applyDemoAddonPolicy } from './demo-addon-policy';
|
||||
import { RequestContext, type EntityManager } from '@mikro-orm/core';
|
||||
|
||||
import fs from 'fs';
|
||||
@@ -155,6 +157,15 @@ async function resetDemoUser(database: DatabaseBackupStrategy): Promise<void> {
|
||||
console.error('[Demo Reset] Failed to restore the active sessions:', e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
// The reopen's schema bootstrap ran the demo seed, which applies the demo
|
||||
// addon set already. Applied here once more so the reset itself states
|
||||
// what the demo offers after the hour, whatever the baseline holds.
|
||||
try {
|
||||
await applyDemoAddonPolicy(demoFresh);
|
||||
} catch (e: unknown) {
|
||||
logError(`[Demo Reset] Failed to apply the demo addon set: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[Demo Reset] Database restored from baseline');
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { readEnv } from '../app-config';
|
||||
import { DemoRepository, type NewDemoPlaceRow } from '../db/repositories/DemoRepository';
|
||||
import { DEMO_PASS } from '../nest/common/demo';
|
||||
import { logInfo } from '../nest/audit/audit-log.logger';
|
||||
import { DEMO_ACCOUNT_SETTINGS, DEMO_PASS } from '../nest/common/demo';
|
||||
import { applyDemoAddonPolicy } from './demo-addon-policy';
|
||||
// Static like in demo-reset.job.ts: the module top is inert, everything that
|
||||
// touches the database happens inside the functions.
|
||||
import { markExampleTripsSeeded, requireEntityManager } from './demo-reset';
|
||||
@@ -53,6 +55,16 @@ async function seedDemoData(): Promise<{ adminId: number; demoId: number }> {
|
||||
// Disable registration in demo mode
|
||||
await demo.setAllowRegistrationFalse();
|
||||
|
||||
// The fixed demo set: the addons every visitor can try, and the demo
|
||||
// account's language. This runs on every boot and again after every reopen
|
||||
// (the hourly reset included), so neither an admin's toggle nor an old
|
||||
// baseline outlasts the hour.
|
||||
await applyDemoAddonPolicy(demo);
|
||||
await demo.setUserSettings(
|
||||
demoId,
|
||||
Object.entries(DEMO_ACCOUNT_SETTINGS).map(([key, value]) => ({ key, value })),
|
||||
);
|
||||
|
||||
// Check if admin already has example trips
|
||||
const adminTripCount = await demo.countTripsByUser(adminId);
|
||||
if (adminTripCount > 0) {
|
||||
@@ -61,6 +73,16 @@ async function seedDemoData(): Promise<{ adminId: number; demoId: number }> {
|
||||
return { adminId, demoId };
|
||||
}
|
||||
|
||||
// A demo whose content belongs to the demo account itself (built over the
|
||||
// API, then saved as the baseline) is left alone. Without this the old
|
||||
// example trips would come back on every reopen, the hourly reset included,
|
||||
// and the mark below would save them into the next baseline.
|
||||
const demoTripCount = await demo.countTripsByUser(demoId);
|
||||
if (demoTripCount > 0) {
|
||||
logInfo('[Demo] The demo account owns its own trips, skipping the example trips');
|
||||
return { adminId, demoId };
|
||||
}
|
||||
|
||||
console.log('[Demo] Seeding example trips...');
|
||||
await seedExampleTrips(demo, adminId, demoId);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { CurrentUser } from '../auth-core/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { currentSessionId, decodeSessionClaims } from '../auth-core/jwt-verify';
|
||||
import { MfaExempt } from '../auth-core/mfa-policy.guard';
|
||||
import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../common/demo-write';
|
||||
import { DEMO_ACCOUNT_REFUSAL, DEMO_WRITE_ERROR, isDemoWriteBlocked, refuseForDemoAccount } from '../common/demo-write';
|
||||
import { ManagedForbidden } from '../common/managed';
|
||||
import { RateLimitService } from '../common/rate-limit.service';
|
||||
import { sessionClientFrom } from '../sessions/sessions.service';
|
||||
@@ -194,6 +194,11 @@ export class AuthController {
|
||||
|
||||
@Put('me/settings')
|
||||
async updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) {
|
||||
// The name and the address belong to the Account tab. Every visitor of a
|
||||
// demo instance shares this account, so they stay what the demo seeded.
|
||||
if (body.username !== undefined || body.email !== undefined) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.account);
|
||||
}
|
||||
const result = await this.profile.updateSettings(user.id, body);
|
||||
await this.auditApiKeys(user.id, result.changedKeys ?? [], req);
|
||||
// The address itself stays out of the row, like the key values above; the row
|
||||
@@ -228,6 +233,7 @@ export class AuthController {
|
||||
|
||||
@Delete('avatar')
|
||||
async deleteAvatar(@CurrentUser() user: User) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.account);
|
||||
return this.profile.deleteAvatar(user.id);
|
||||
}
|
||||
|
||||
@@ -312,6 +318,7 @@ export class AuthController {
|
||||
@Post('mcp-tokens')
|
||||
@HttpCode(201)
|
||||
async createMcpToken(@CurrentUser() user: User, @Body() body: McpTokenCreateDto, @Req() req: Request) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
|
||||
await this.limit('login', req, 5);
|
||||
const result = await this.tokens.createMcpToken(user.id, body.name);
|
||||
return { token: result.token };
|
||||
@@ -319,6 +326,7 @@ export class AuthController {
|
||||
|
||||
@Delete('mcp-tokens/:id')
|
||||
async deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
|
||||
await this.tokens.deleteMcpToken(user.id, id);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -343,6 +351,7 @@ export class AuthController {
|
||||
@Post('api-tokens')
|
||||
@HttpCode(201)
|
||||
async createApiToken(@CurrentUser() user: User, @Body() body: ApiTokenCreateDto, @Req() req: Request) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.apiKeys);
|
||||
await this.limit('login', req, 5);
|
||||
// No `scopes` means the key reads everything, which is what every key minted
|
||||
// before this field existed does. Narrowing stays opt-in so the change
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CurrentUser } from '../auth-core/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { MfaExempt } from '../auth-core/mfa-policy.guard';
|
||||
import { setAuthCookie } from '../common/cookie';
|
||||
import { DEMO_ACCOUNT_REFUSAL, refuseForDemoAccount } from '../common/demo-write';
|
||||
import { RateLimitService } from '../common/rate-limit.service';
|
||||
import { sessionClientFrom } from '../sessions/sessions.service';
|
||||
import {
|
||||
@@ -69,6 +70,9 @@ export class PasskeyController {
|
||||
@HttpCode(200)
|
||||
@UseGuards(PasskeyEnabledGuard, JwtAuthGuard)
|
||||
async registerOptions(@CurrentUser() user: User, @Body() body: PasskeyRegisterOptionsDto, @Req() req: Request) {
|
||||
// Every visitor shares the demo account: a passkey enrolled on it would be
|
||||
// one stranger's key to everybody's demo.
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys);
|
||||
await this.limit('mfa', req, 5);
|
||||
// The Origin header is only a pre-ceremony sanity input (see PasskeyService)
|
||||
// — the RP ID itself is never derived from request headers.
|
||||
@@ -81,6 +85,7 @@ export class PasskeyController {
|
||||
@HttpCode(200)
|
||||
@UseGuards(PasskeyEnabledGuard, JwtAuthGuard)
|
||||
async registerVerify(@CurrentUser() user: User, @Body() body: PasskeyRegisterVerifyDto, @Req() req: Request) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys);
|
||||
const result = await this.passkeys.passkeyRegisterVerify(user.id, body);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) });
|
||||
return { success: true, credential: result.credential };
|
||||
@@ -142,6 +147,7 @@ export class PasskeyController {
|
||||
@Patch('credentials/:id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async rename(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyRenameDto) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys);
|
||||
await this.passkeys.renamePasskey(user.id, id, body?.name);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -154,6 +160,7 @@ export class PasskeyController {
|
||||
@Body() body: PasskeyDeleteDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys);
|
||||
await this.limit('login', req, 5);
|
||||
await this.passkeys.deletePasskey(user.id, id, body?.password);
|
||||
await this.audit.writeAudit({
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { readEnv } from '../../app-config';
|
||||
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
import { isDemoEmail } from './demo';
|
||||
import { DEMO_ACCOUNT_SETTINGS, isDemoEmail } from './demo';
|
||||
import { DomainError } from './domain-error';
|
||||
|
||||
/** The exact 403 body the six upload endpoints have always returned. */
|
||||
export const DEMO_WRITE_ERROR = {
|
||||
@@ -29,3 +31,48 @@ export function isDemoWriteBlocked(env: RuntimeEnvService, email: string | null
|
||||
// `this.demo.isDemoUserId(userId)` instead: the same check, resolving `env`/
|
||||
// the repository via its own constructor rather than taking them as
|
||||
// parameters.
|
||||
|
||||
/**
|
||||
* The shared account every visitor of a demo instance signs in as. The same
|
||||
* reading of the environment as the password, MFA and session refusals in
|
||||
* `auth/`, so a controller that has no RuntimeEnvService of its own (passkeys,
|
||||
* OAuth) asks the same question without a new constructor parameter.
|
||||
*/
|
||||
export function isDemoAccount(user: { email?: string | null } | null | undefined): boolean {
|
||||
return readEnv().demo.enabled && isDemoEmail(user?.email);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the demo account is told when it tries to change something every other
|
||||
* visitor shares. One sentence per area, so the client can show it as it is.
|
||||
*/
|
||||
export const DEMO_ACCOUNT_REFUSAL = {
|
||||
apiKeys: 'API keys cannot be created in demo mode.',
|
||||
mcp: 'MCP clients cannot be connected in demo mode.',
|
||||
account: 'The demo account cannot be changed in demo mode.',
|
||||
passkeys: 'Passkeys cannot be changed in demo mode.',
|
||||
} as const;
|
||||
|
||||
/** Refuses the write with a 403 for the demo account; does nothing for anybody else. */
|
||||
export function refuseForDemoAccount(user: { email?: string | null } | null | undefined, message: string): void {
|
||||
if (isDemoAccount(user)) throw new DomainError(403, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a settings write of this key is one the demo account does not get to
|
||||
* make (`DEMO_ACCOUNT_SETTINGS`). The route answers as if it saved nothing,
|
||||
* rather than refusing: a client that sends the language along with other keys
|
||||
* keeps the rest of its save.
|
||||
*/
|
||||
export function isDemoLockedSetting(user: { email?: string | null } | null | undefined, key: string): boolean {
|
||||
return Object.hasOwn(DEMO_ACCOUNT_SETTINGS, key) && isDemoAccount(user);
|
||||
}
|
||||
|
||||
/** The settings of a bulk save without the keys the demo account keeps fixed. */
|
||||
export function withoutDemoLockedSettings(
|
||||
user: { email?: string | null } | null | undefined,
|
||||
settings: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (!isDemoAccount(user)) return settings;
|
||||
return Object.fromEntries(Object.entries(settings).filter(([key]) => !Object.hasOwn(DEMO_ACCOUNT_SETTINGS, key)));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { DEMO_ACCOUNT_EMAILS } from '@trek/shared';
|
||||
|
||||
// Central registry of demo-user email addresses.
|
||||
//
|
||||
// Historical: the demo account was seeded as "demo@trek.app" (see
|
||||
@@ -23,8 +25,17 @@ export const DEMO_PASS = 'demo12345';
|
||||
* Includes the historical `demo@nomad.app` identifier so instances that
|
||||
* upgraded in place without resetting the DB still hit demo-mode guards.
|
||||
*/
|
||||
export const DEMO_EMAILS: ReadonlySet<string> = new Set([DEMO_EMAIL_PRIMARY, 'demo@nomad.app']);
|
||||
export const DEMO_EMAILS: ReadonlySet<string> = new Set(DEMO_ACCOUNT_EMAILS);
|
||||
|
||||
export function isDemoEmail(email: string | null | undefined): boolean {
|
||||
return !!email && DEMO_EMAILS.has(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* User settings the shared demo account always holds, whatever a visitor picks.
|
||||
* The demo seed writes them on every boot and after every hourly reset, and the
|
||||
* settings routes leave them alone for the demo account (`demo-write.ts`), so a
|
||||
* visitor cannot switch the demo every other visitor sees into another
|
||||
* language.
|
||||
*/
|
||||
export const DEMO_ACCOUNT_SETTINGS: Readonly<Record<string, string>> = { language: 'en' };
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CookieAuthGuard } from '../auth-core/cookie-auth.guard';
|
||||
import { CurrentUser } from '../auth-core/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { OptionalJwtGuard } from '../auth-core/optional-jwt.guard';
|
||||
import { DEMO_ACCOUNT_REFUSAL, refuseForDemoAccount } from '../common/demo-write';
|
||||
import { RateLimitService } from '../common/rate-limit.service';
|
||||
import { OauthClientCreateDto, OauthConsentDto } from './oauth.dto';
|
||||
import { OauthService } from './oauth.service';
|
||||
@@ -122,6 +123,8 @@ export class OauthApiController {
|
||||
if (body.state) url.searchParams.set('state', body.state);
|
||||
return { redirect: url.toString() };
|
||||
}
|
||||
// Saying no stays possible. Handing the shared demo account to an app does not.
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
|
||||
const scopes = validation.scopes!;
|
||||
await this.oauth.saveConsent(body.client_id, user.id, scopes, ip);
|
||||
const code = await this.oauth.createAuthCode({
|
||||
@@ -157,6 +160,7 @@ export class OauthApiController {
|
||||
@UseGuards(CookieAuthGuard)
|
||||
async createClient(@CurrentUser() user: User, @Body() body: OauthClientCreateDto, @Req() req: Request) {
|
||||
await this.requireMcp403();
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
|
||||
const result = await this.oauth.createOAuthClient(
|
||||
user.id,
|
||||
body.name,
|
||||
@@ -176,6 +180,7 @@ export class OauthApiController {
|
||||
@UseGuards(CookieAuthGuard)
|
||||
async rotateClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
await this.requireMcp403();
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
|
||||
const result = await this.oauth.rotateOAuthClientSecret(user.id, id, getClientIp(req));
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
@@ -187,6 +192,7 @@ export class OauthApiController {
|
||||
@UseGuards(CookieAuthGuard)
|
||||
async deleteClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
await this.requireMcp403();
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
|
||||
const result = await this.oauth.deleteOAuthClient(user.id, id, getClientIp(req));
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
@@ -205,6 +211,7 @@ export class OauthApiController {
|
||||
@UseGuards(CookieAuthGuard)
|
||||
async revokeSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
await this.requireMcp403();
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
|
||||
const result = await this.oauth.revokeSession(user.id, Number(id), getClientIp(req));
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getClientIp } from '../audit/client-ip';
|
||||
import { AdminGuard } from '../auth-core/admin.guard';
|
||||
import { CurrentUser } from '../auth-core/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { isDemoLockedSetting, withoutDemoLockedSettings } from '../common/demo-write';
|
||||
import { isManagedLockedKey, splitManagedKeys } from '../common/managed';
|
||||
import { ResponseContract } from '../common/response-contract';
|
||||
import { SettingsWritesService } from './settings-writes.service';
|
||||
@@ -99,6 +100,10 @@ export class SettingsController {
|
||||
if (isManagedLockedKey(body.key) && this.env.isManaged()) {
|
||||
return { success: true, key: body.key, managed: true };
|
||||
}
|
||||
// The demo account keeps its language: every visitor sees the same demo.
|
||||
if (isDemoLockedSetting(user, body.key)) {
|
||||
return { success: true, key: body.key, unchanged: true };
|
||||
}
|
||||
// The client echoes a redacted secret back unchanged — treat as a no-op.
|
||||
if (body.value === MASKED_SETTING_VALUE) {
|
||||
return { success: true, key: body.key, unchanged: true };
|
||||
@@ -113,7 +118,7 @@ export class SettingsController {
|
||||
async bulk(@CurrentUser() user: User, @Body() body: SettingsBulkDto, @Headers('x-socket-id') socketId?: string) {
|
||||
this.assertMayWriteInstanceEndpoint(body.settings);
|
||||
this.assertValidValues(body.settings);
|
||||
const { allowed, blocked } = splitManagedKeys(body.settings, this.env.isManaged());
|
||||
const { allowed, blocked } = splitManagedKeys(withoutDemoLockedSettings(user, body.settings), this.env.isManaged());
|
||||
try {
|
||||
const updated = await this.writes.saveMany(user.id, allowed, socketId);
|
||||
return { success: true, updated, ...(blocked.length ? { managed_keys: blocked } : {}) };
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Demo account locks e2e: on a demo instance the shared demo account cannot
|
||||
* mint API keys or MCP tokens, change its name, address or picture, or touch
|
||||
* passkeys, and its language stays English. Every visitor signs in as that one
|
||||
* account, so each of these would be one stranger's change to everybody's demo.
|
||||
*
|
||||
* Boots AuthModule (with the passkey routes) and SettingsModule with the real
|
||||
* JwtAuthGuard, the Zod pipe and the exception filter over a migrated temp
|
||||
* SQLite db. The other accounts on the same instance, and the demo address on
|
||||
* an instance that is not a demo, keep every one of these routes.
|
||||
*/
|
||||
import { Settings } from '../../src/db/entities/Settings.entity';
|
||||
import { AppConfigModule } from '../../src/nest/app-config/app-config.module';
|
||||
import { AuthModule } from '../../src/nest/auth/auth.module';
|
||||
import { DEMO_ACCOUNT_REFUSAL } from '../../src/nest/common/demo-write';
|
||||
import { DemoModule } from '../../src/nest/common/demo.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
import { MailerService } from '../../src/nest/notifications/mailer/mailer.service';
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
|
||||
import { SettingsModule } from '../../src/nest/settings/settings.module';
|
||||
import { createSnapshotTestDb } from '../helpers/db-mock';
|
||||
import { deleteRows, findRow, insertRow } from '../helpers/factories/rows';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { FakeRealtimeService } from '../helpers/fake-realtime';
|
||||
import { resetRateLimits } from '../helpers/test-db';
|
||||
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
import { sessionCookie } from './harness';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import request from 'supertest';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
|
||||
LOG_LEVEL: 'error',
|
||||
logInfo: vi.fn(),
|
||||
logDebug: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
const DEMO = 1;
|
||||
const VISITOR_ADMIN = 2;
|
||||
|
||||
let orm: TestOrm;
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [
|
||||
await TestUnitOfWorkModule.forRoot(db),
|
||||
await createTestMikroOrmModule(db),
|
||||
AppConfigModule,
|
||||
RealtimeModule,
|
||||
DemoModule,
|
||||
AuthModule,
|
||||
SettingsModule,
|
||||
],
|
||||
})
|
||||
.overrideProvider(RealtimeService)
|
||||
.useValue(new FakeRealtimeService())
|
||||
.overrideProvider(MailerService)
|
||||
.useValue({ sendPasswordResetEmail: vi.fn() })
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalPipes(new ZodValidationPipe());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
const as = (userId: number) => ({ Cookie: sessionCookie(userId) });
|
||||
|
||||
async function languageOf(userId: number): Promise<string | undefined> {
|
||||
return (await findRow(orm, Settings, { user: userId, key: 'language' }))?.value;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
orm = await createTestOrm(db);
|
||||
await makeUser(orm, { id: DEMO, username: 'demo', email: 'demo@trek.app' });
|
||||
await makeUser(orm, { id: VISITOR_ADMIN, username: 'admin', email: 'admin@example.test', role: 'admin' });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.stubEnv('DEMO_MODE', 'true');
|
||||
await resetRateLimits(app);
|
||||
await deleteRows(orm, Settings);
|
||||
await insertRow(orm, Settings, { user: DEMO, key: 'language', value: 'en' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await orm.close();
|
||||
});
|
||||
|
||||
describe('Demo account locks e2e', () => {
|
||||
it('DEMOLOCK-001: the demo account cannot create an API key', async () => {
|
||||
const res = await request(server).post('/api/auth/api-tokens').set(as(DEMO)).send({ name: 'Mine' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.apiKeys });
|
||||
});
|
||||
|
||||
it('DEMOLOCK-002: the demo account can neither create nor delete an MCP token', async () => {
|
||||
const created = await request(server).post('/api/auth/mcp-tokens').set(as(DEMO)).send({ name: 'Claude' });
|
||||
expect(created.status).toBe(403);
|
||||
expect(created.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.mcp });
|
||||
|
||||
const removed = await request(server).delete('/api/auth/mcp-tokens/1').set(as(DEMO));
|
||||
expect(removed.status).toBe(403);
|
||||
expect(removed.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.mcp });
|
||||
});
|
||||
|
||||
it('DEMOLOCK-003: the name, the address and the picture of the demo account stay as seeded', async () => {
|
||||
const name = await request(server).put('/api/auth/me/settings').set(as(DEMO)).send({ username: 'renamed' });
|
||||
expect(name.status).toBe(403);
|
||||
expect(name.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.account });
|
||||
|
||||
const address = await request(server)
|
||||
.put('/api/auth/me/settings')
|
||||
.set(as(DEMO))
|
||||
.send({ email: 'mine@example.test' });
|
||||
expect(address.status).toBe(403);
|
||||
|
||||
const avatar = await request(server).delete('/api/auth/avatar').set(as(DEMO));
|
||||
expect(avatar.status).toBe(403);
|
||||
expect(avatar.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.account });
|
||||
});
|
||||
|
||||
it('DEMOLOCK-004: passkeys of the demo account can be neither renamed nor deleted', async () => {
|
||||
const renamed = await request(server)
|
||||
.patch('/api/auth/passkey/credentials/abc')
|
||||
.set(as(DEMO))
|
||||
.send({ name: 'Laptop' });
|
||||
expect(renamed.status).toBe(403);
|
||||
expect(renamed.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.passkeys });
|
||||
|
||||
const deleted = await request(server)
|
||||
.delete('/api/auth/passkey/credentials/abc')
|
||||
.set(as(DEMO))
|
||||
.send({ password: 'demo12345' });
|
||||
expect(deleted.status).toBe(403);
|
||||
expect(deleted.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.passkeys });
|
||||
});
|
||||
|
||||
it('DEMOLOCK-005: a language picked on the demo account is answered as unchanged and not stored', async () => {
|
||||
const one = await request(server).put('/api/settings').set(as(DEMO)).send({ key: 'language', value: 'de' });
|
||||
expect(one.status).toBe(200);
|
||||
expect(one.body).toEqual({ success: true, key: 'language', unchanged: true });
|
||||
expect(await languageOf(DEMO)).toBe('en');
|
||||
});
|
||||
|
||||
it('DEMOLOCK-006: a bulk save of the demo account keeps every other key and drops the language', async () => {
|
||||
const res = await request(server)
|
||||
.post('/api/settings/bulk')
|
||||
.set(as(DEMO))
|
||||
.send({ settings: { language: 'fr', temperature_unit: 'fahrenheit' } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true, updated: 1 });
|
||||
expect(await languageOf(DEMO)).toBe('en');
|
||||
|
||||
const settings = await request(server).get('/api/settings').set(as(DEMO));
|
||||
expect(settings.body.settings).toEqual(expect.objectContaining({ language: 'en', temperature_unit: 'fahrenheit' }));
|
||||
});
|
||||
|
||||
it('DEMOLOCK-007: the admin of a demo instance keeps every one of these routes', async () => {
|
||||
const token = await request(server).post('/api/auth/api-tokens').set(as(VISITOR_ADMIN)).send({ name: 'Admin' });
|
||||
expect(token.status).toBe(201);
|
||||
|
||||
const language = await request(server)
|
||||
.put('/api/settings')
|
||||
.set(as(VISITOR_ADMIN))
|
||||
.send({ key: 'language', value: 'de' });
|
||||
expect(language.status).toBe(200);
|
||||
expect(await languageOf(VISITOR_ADMIN)).toBe('de');
|
||||
});
|
||||
|
||||
it('DEMOLOCK-008: off a demo instance the demo address is an account like any other', async () => {
|
||||
vi.stubEnv('DEMO_MODE', 'false');
|
||||
const token = await request(server).post('/api/auth/api-tokens').set(as(DEMO)).send({ name: 'Mine' });
|
||||
expect(token.status).toBe(201);
|
||||
|
||||
await request(server).put('/api/settings').set(as(DEMO)).send({ key: 'language', value: 'de' });
|
||||
expect(await languageOf(DEMO)).toBe('de');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* The demo addon policy: which addons, providers and addon switches a demo
|
||||
* instance runs with, and that the seed and the hourly reset both put them back.
|
||||
*
|
||||
* The lists are explicit on purpose, so a release that adds an addon has to say
|
||||
* whether a demo shows it: DEMOPOL-001 fails on a seeded row that is in neither
|
||||
* list, and on a listed id the database does not hold.
|
||||
*/
|
||||
import { Addons } from '../../../src/db/entities/Addons.entity';
|
||||
import { DocumentProviders } from '../../../src/db/entities/DocumentProviders.entity';
|
||||
import { PhotoProviders } from '../../../src/db/entities/PhotoProviders.entity';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
import { DEMO_ADDON_POLICY } from '../../../src/demo/demo-addon-policy';
|
||||
import { resetDemoUser } from '../../../src/demo/demo-reset';
|
||||
import { seedDemoData } from '../../../src/demo/demo-seed';
|
||||
import type { DatabaseBackupStrategy } from '../../../src/nest/database/database-backup.interface';
|
||||
import { withRequestContext } from '../../../src/nest/database/request-context';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { findRow, findRows, updateRows } from '../../helpers/factories/rows';
|
||||
import { readAppSetting, readUserSetting, setAppSetting, setUserSetting } from '../../helpers/factories/settings';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const BASELINE = path.resolve(__dirname, '..', '..', '..', 'data', 'travel-baseline.db');
|
||||
|
||||
let db: Database.Database;
|
||||
let t: TestOrm;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
db = createSnapshotTestDb();
|
||||
t = await createTestOrm(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
const seed = () => withRequestContext(t.orm, () => seedDemoData());
|
||||
|
||||
async function enabledIds(entity: typeof Addons | typeof PhotoProviders | typeof DocumentProviders) {
|
||||
const rows = (await findRows(t, entity as typeof Addons)) as { id: string; enabled: unknown }[];
|
||||
return {
|
||||
on: rows
|
||||
.filter((r) => !!r.enabled)
|
||||
.map((r) => r.id)
|
||||
.sort(),
|
||||
off: rows
|
||||
.filter((r) => !r.enabled)
|
||||
.map((r) => r.id)
|
||||
.sort(),
|
||||
};
|
||||
}
|
||||
|
||||
const sorted = (ids: readonly string[]) => [...ids].sort();
|
||||
|
||||
/** What an admin of a demo instance might have switched during the hour. */
|
||||
async function scramble(): Promise<void> {
|
||||
await updateRows(t, Addons, { id: 'airtrail' }, { enabled: true });
|
||||
await updateRows(t, Addons, { id: 'packing' }, { enabled: false });
|
||||
await updateRows(t, Addons, { id: 'journey' }, { enabled: false });
|
||||
await updateRows(t, PhotoProviders, { id: 'immich' }, { enabled: 1 });
|
||||
await updateRows(t, DocumentProviders, { id: 'paperless' }, { enabled: 1 });
|
||||
await setAppSetting(t, 'tours_wanderer_enabled', 'true');
|
||||
await setAppSetting(t, 'collab_chat_enabled', 'false');
|
||||
}
|
||||
|
||||
async function expectPolicyApplied(): Promise<void> {
|
||||
expect(await enabledIds(Addons)).toEqual({
|
||||
on: sorted(DEMO_ADDON_POLICY.addons.on),
|
||||
off: sorted(DEMO_ADDON_POLICY.addons.off),
|
||||
});
|
||||
expect(await enabledIds(PhotoProviders)).toEqual({ on: [], off: sorted(DEMO_ADDON_POLICY.photoProviders.off) });
|
||||
expect(await enabledIds(DocumentProviders)).toEqual({
|
||||
on: [],
|
||||
off: sorted(DEMO_ADDON_POLICY.documentProviders.off),
|
||||
});
|
||||
const keys = Object.keys(DEMO_ADDON_POLICY.appSettings);
|
||||
const values = await Promise.all(keys.map((key) => readAppSetting(t, key)));
|
||||
expect(Object.fromEntries(keys.map((key, i) => [key, values[i]]))).toEqual(DEMO_ADDON_POLICY.appSettings);
|
||||
}
|
||||
|
||||
describe('demo addon policy', () => {
|
||||
it('DEMOPOL-001: names every seeded addon and provider exactly once, and nothing the database lacks', async () => {
|
||||
const shelves = [
|
||||
[Addons, DEMO_ADDON_POLICY.addons],
|
||||
[PhotoProviders, DEMO_ADDON_POLICY.photoProviders],
|
||||
[DocumentProviders, DEMO_ADDON_POLICY.documentProviders],
|
||||
] as const;
|
||||
const seeded = await Promise.all(shelves.map(([entity]) => findRows(t, entity as typeof Addons)));
|
||||
shelves.forEach(([, shelf], i) => {
|
||||
const listed = [...shelf.on, ...shelf.off];
|
||||
expect(new Set(listed).size).toBe(listed.length);
|
||||
expect(sorted(listed)).toEqual(seeded[i].map((r) => r.id).sort());
|
||||
});
|
||||
});
|
||||
|
||||
it('DEMOPOL-002: keeps off exactly what reaches a server of the visitor’s own, the AI features, MCP and the legacy Photos row', () => {
|
||||
expect(sorted(DEMO_ADDON_POLICY.addons.off)).toEqual([
|
||||
'ai_assistant',
|
||||
'airtrail',
|
||||
'dawarich',
|
||||
'llm_parsing',
|
||||
'mcp',
|
||||
'memories',
|
||||
]);
|
||||
expect(sorted(DEMO_ADDON_POLICY.photoProviders.off)).toEqual(['immich', 'synologyphotos']);
|
||||
expect(sorted(DEMO_ADDON_POLICY.documentProviders.off)).toEqual(
|
||||
sorted(['paperless', 'papra', 'nextcloud', 'opencloud', 'synologydrive']),
|
||||
);
|
||||
expect(DEMO_ADDON_POLICY.appSettings.tours_wanderer_enabled).toBe('false');
|
||||
const otherSwitches = Object.entries(DEMO_ADDON_POLICY.appSettings).filter(
|
||||
([key]) => key !== 'tours_wanderer_enabled',
|
||||
);
|
||||
expect(otherSwitches.every(([, value]) => value === 'true')).toBe(true);
|
||||
});
|
||||
|
||||
it('DEMOPOL-003: the first demo boot puts the whole shelf to the demo set', async () => {
|
||||
await scramble();
|
||||
await seed();
|
||||
await expectPolicyApplied();
|
||||
});
|
||||
|
||||
it('DEMOPOL-004: a later boot, with the example trips already there, puts it back again', async () => {
|
||||
await seed();
|
||||
await scramble();
|
||||
await seed();
|
||||
await expectPolicyApplied();
|
||||
});
|
||||
|
||||
it('DEMOPOL-005: the demo account runs in English, whatever a visitor picked before', async () => {
|
||||
const { demoId } = await seed();
|
||||
await setUserSetting(t, demoId, 'language', 'de');
|
||||
await seed();
|
||||
expect(await readUserSetting(t, demoId, 'language')).toBe('en');
|
||||
});
|
||||
|
||||
it('DEMOPOL-006: the admin account keeps the language it chose', async () => {
|
||||
const { adminId } = await seed();
|
||||
await setUserSetting(t, adminId, 'language', 'de');
|
||||
await seed();
|
||||
expect(await readUserSetting(t, adminId, 'language')).toBe('de');
|
||||
});
|
||||
});
|
||||
|
||||
describe('demo addon policy across the hourly reset', () => {
|
||||
it('DEMOPOL-007: the reset puts the demo set back after the baseline swap, whatever the baseline held', async () => {
|
||||
await seed();
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation(((p: fs.PathLike) => String(p) === BASELINE) as typeof fs.existsSync);
|
||||
// The stand-in swap brings back a baseline whose shelf an admin had changed.
|
||||
const database = {
|
||||
canSnapshot: () => true,
|
||||
checkpoint: vi.fn(async () => undefined),
|
||||
replace: vi.fn(async () => {
|
||||
await scramble();
|
||||
return { reopenError: undefined };
|
||||
}),
|
||||
} as unknown as DatabaseBackupStrategy;
|
||||
|
||||
await withRequestContext(t.orm, () => resetDemoUser(database));
|
||||
t.clear();
|
||||
|
||||
expect(database.replace).toHaveBeenCalled();
|
||||
await expectPolicyApplied();
|
||||
expect(await findRow(t, Users, { email: 'demo@trek.app' })).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ import { takeExampleTripsSeeded } from '../../../src/demo/demo-reset';
|
||||
import { seedDemoData } from '../../../src/demo/demo-seed';
|
||||
import { withRequestContext } from '../../../src/nest/database/request-context';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { countRows, findRows } from '../../helpers/factories/rows';
|
||||
import { countRows, deleteRows, findRows, updateRows } from '../../helpers/factories/rows';
|
||||
import { readAppSetting } from '../../helpers/factories/settings';
|
||||
import { readUser } from '../../helpers/factories/users';
|
||||
import { createTestOrm } from '../../helpers/test-orm';
|
||||
@@ -167,4 +167,28 @@ describe('demo seeding', () => {
|
||||
await seed();
|
||||
expect(takeExampleTripsSeeded()).toBe(false);
|
||||
});
|
||||
|
||||
it('DEMOSEED-006: leaves a demo alone whose trips belong to the demo account, not the admin', async () => {
|
||||
takeExampleTripsSeeded();
|
||||
const { adminId, demoId } = await seed();
|
||||
expect(takeExampleTripsSeeded()).toBe(true);
|
||||
|
||||
// The demo content is rebuilt as the demo user's own: the admin's example
|
||||
// trips go, one trip owned by the demo account stays.
|
||||
const orm = await createTestOrm(db);
|
||||
const exampleTrips = await findRows(orm, Trips, { user_id: adminId }, { id: 'asc' });
|
||||
const keep = exampleTrips[0];
|
||||
await deleteRows(orm, Trips, { id: { $in: exampleTrips.slice(1).map((t) => t.id) } });
|
||||
await updateRows(orm, Trips, { id: keep.id }, { user: demoId });
|
||||
await orm.close();
|
||||
|
||||
await seed();
|
||||
|
||||
const after = await createTestOrm(db);
|
||||
const trips = await findRows(after, Trips, {}, { id: 'asc' });
|
||||
await after.close();
|
||||
expect(trips.map((t) => ({ id: t.id, user_id: t.user_id }))).toEqual([{ id: keep.id, user_id: demoId }]);
|
||||
// Nothing was seeded, so no baseline is due either.
|
||||
expect(takeExampleTripsSeeded()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,11 +12,23 @@
|
||||
*/
|
||||
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||
import { isDemoEmail } from '../../../src/nest/common/demo';
|
||||
import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../../../src/nest/common/demo-write';
|
||||
import {
|
||||
DEMO_ACCOUNT_REFUSAL,
|
||||
DEMO_WRITE_ERROR,
|
||||
isDemoAccount,
|
||||
isDemoLockedSetting,
|
||||
isDemoWriteBlocked,
|
||||
refuseForDemoAccount,
|
||||
withoutDemoLockedSettings,
|
||||
} from '../../../src/nest/common/demo-write';
|
||||
import { DomainError } from '../../../src/nest/common/domain-error';
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('../../../src/nest/common/demo', () => ({ isDemoEmail: vi.fn(() => false) }));
|
||||
vi.mock('../../../src/nest/common/demo', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../src/nest/common/demo')>()),
|
||||
isDemoEmail: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
const env = new RuntimeEnvService();
|
||||
|
||||
@@ -60,3 +72,50 @@ describe('isDemoWriteBlocked', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the demo account locks', () => {
|
||||
const demo = { email: 'demo@trek.app' };
|
||||
const other = { email: 'alice@example.com' };
|
||||
const onDemoInstance = () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
vi.mocked(isDemoEmail).mockImplementation((email) => email === 'demo@trek.app');
|
||||
};
|
||||
|
||||
it('DEMO-WRITE-006: isDemoAccount is the demo address on a demo instance, and nothing else', () => {
|
||||
vi.mocked(isDemoEmail).mockImplementation((email) => email === 'demo@trek.app');
|
||||
expect(isDemoAccount(demo)).toBe(false);
|
||||
onDemoInstance();
|
||||
expect(isDemoAccount(demo)).toBe(true);
|
||||
expect(isDemoAccount(other)).toBe(false);
|
||||
expect(isDemoAccount(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('DEMO-WRITE-007: refuseForDemoAccount throws a 403 with the message for the demo account only', () => {
|
||||
onDemoInstance();
|
||||
expect(() => refuseForDemoAccount(other, DEMO_ACCOUNT_REFUSAL.apiKeys)).not.toThrow();
|
||||
let thrown: unknown;
|
||||
try {
|
||||
refuseForDemoAccount(demo, DEMO_ACCOUNT_REFUSAL.apiKeys);
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(DomainError);
|
||||
expect((thrown as DomainError).getStatus()).toBe(403);
|
||||
expect((thrown as DomainError).getResponse()).toEqual({ error: 'API keys cannot be created in demo mode.' });
|
||||
});
|
||||
|
||||
it('DEMO-WRITE-008: the language is the setting the demo account keeps', () => {
|
||||
onDemoInstance();
|
||||
expect(isDemoLockedSetting(demo, 'language')).toBe(true);
|
||||
expect(isDemoLockedSetting(demo, 'temperature_unit')).toBe(false);
|
||||
expect(isDemoLockedSetting(other, 'language')).toBe(false);
|
||||
});
|
||||
|
||||
it('DEMO-WRITE-009: a bulk save of the demo account loses the language and keeps the rest', () => {
|
||||
const settings = { language: 'de', distance_unit: 'imperial' };
|
||||
expect(withoutDemoLockedSettings(demo, settings)).toBe(settings);
|
||||
onDemoInstance();
|
||||
expect(withoutDemoLockedSettings(demo, settings)).toEqual({ distance_unit: 'imperial' });
|
||||
expect(withoutDemoLockedSettings(other, settings)).toBe(settings);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* The OAuth half of the MCP lock on a demo instance: the shared demo account
|
||||
* cannot register, rotate or delete an OAuth client, end a connected app, or
|
||||
* approve an app's request for access. Turning a request down stays possible.
|
||||
* Every refusal comes before the service is asked, so nothing is written.
|
||||
*
|
||||
* The controller comes out of OauthModule with OauthService replaced by the
|
||||
* stand-in below, so it is wired the way the app wires it.
|
||||
*/
|
||||
import { DEMO_ACCOUNT_REFUSAL } from '../../../src/nest/common/demo-write';
|
||||
import { OauthApiController } from '../../../src/nest/oauth/oauth-api.controller';
|
||||
import { OauthModule } from '../../../src/nest/oauth/oauth.module';
|
||||
import { OauthService } from '../../../src/nest/oauth/oauth.service';
|
||||
import type { User } from '../../../src/types';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
|
||||
import type { Request } from 'express';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') }));
|
||||
|
||||
const demo = { id: 1, role: 'user', email: 'demo@trek.app' } as User;
|
||||
const other = { id: 2, role: 'user', email: 'alice@example.test' } as User;
|
||||
const req = { ip: '1.2.3.4', headers: {} } as unknown as Request;
|
||||
|
||||
function service() {
|
||||
return {
|
||||
mcpEnabled: vi.fn().mockResolvedValue(true),
|
||||
validateAuthorizeRequest: vi.fn().mockResolvedValue({ valid: true, scopes: ['trips:read'], resource: null }),
|
||||
saveConsent: vi.fn(),
|
||||
createAuthCode: vi.fn().mockResolvedValue('the_code'),
|
||||
createOAuthClient: vi.fn().mockResolvedValue({ client_id: 'c1', client_secret: 's' }),
|
||||
rotateOAuthClientSecret: vi.fn().mockResolvedValue({ client_secret: 's2' }),
|
||||
deleteOAuthClient: vi.fn().mockResolvedValue({}),
|
||||
revokeSession: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
}
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
// The module hands the controller this very object, so each test re-arms its
|
||||
// mocks in place instead of building a new container.
|
||||
const svc = service();
|
||||
let t: TestModule;
|
||||
let api: OauthApiController;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({ db, imports: [OauthModule], overrides: [{ provide: OauthService, useValue: svc }] });
|
||||
api = t.get(OauthApiController);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
async function refusal(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected a refusal');
|
||||
}
|
||||
|
||||
const consent = (approved: boolean) => ({
|
||||
client_id: 'c1',
|
||||
redirect_uri: 'https://app.example.test/cb',
|
||||
scope: 'trips:read',
|
||||
state: 'st',
|
||||
code_challenge: 'cc',
|
||||
code_challenge_method: 'S256' as const,
|
||||
approved,
|
||||
});
|
||||
|
||||
const MCP_REFUSAL = { status: 403, body: { error: DEMO_ACCOUNT_REFUSAL.mcp } };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('DEMO_MODE', 'true');
|
||||
Object.assign(svc, service());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('OAuth routes for the demo account', () => {
|
||||
it('OAUTH-DEMO-001: registering, rotating and deleting a client are refused before the service runs', async () => {
|
||||
expect(await refusal(() => api.createClient(demo, { name: 'CLI', allowed_scopes: ['a'] }, req))).toEqual(
|
||||
MCP_REFUSAL,
|
||||
);
|
||||
expect(await refusal(() => api.rotateClient(demo, 'c1', req))).toEqual(MCP_REFUSAL);
|
||||
expect(await refusal(() => api.deleteClient(demo, 'c1', req))).toEqual(MCP_REFUSAL);
|
||||
expect(svc.createOAuthClient).not.toHaveBeenCalled();
|
||||
expect(svc.rotateOAuthClientSecret).not.toHaveBeenCalled();
|
||||
expect(svc.deleteOAuthClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('OAUTH-DEMO-002: a connected app cannot be ended from the demo account', async () => {
|
||||
expect(await refusal(() => api.revokeSession(demo, '7', req))).toEqual(MCP_REFUSAL);
|
||||
expect(svc.revokeSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('OAUTH-DEMO-003: approving an app is refused, turning it down still redirects', async () => {
|
||||
expect(await refusal(() => api.authorize(demo, consent(true), req))).toEqual(MCP_REFUSAL);
|
||||
expect(svc.saveConsent).not.toHaveBeenCalled();
|
||||
expect(svc.createAuthCode).not.toHaveBeenCalled();
|
||||
|
||||
const denied = await api.authorize(demo, consent(false), req);
|
||||
expect((denied as { redirect: string }).redirect).toContain('error=access_denied');
|
||||
});
|
||||
|
||||
it('OAUTH-DEMO-004: every other account of the demo instance keeps the routes', async () => {
|
||||
expect(await api.createClient(other, { name: 'CLI', allowed_scopes: ['a'] }, req)).toEqual({
|
||||
client_id: 'c1',
|
||||
client_secret: 's',
|
||||
});
|
||||
const approved = await api.authorize(other, consent(true), req);
|
||||
expect((approved as { redirect: string }).redirect).toContain('code=the_code');
|
||||
});
|
||||
|
||||
it('OAUTH-DEMO-005: off a demo instance the demo address keeps them too', async () => {
|
||||
vi.stubEnv('DEMO_MODE', 'false');
|
||||
expect(await api.deleteClient(demo, 'c1', req)).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { DEMO_ACCOUNT_EMAILS, isDemoAccountEmail } from './demo-account';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('isDemoAccountEmail', () => {
|
||||
it('knows the current and the pre-rename demo address', () => {
|
||||
expect(DEMO_ACCOUNT_EMAILS).toEqual(['demo@trek.app', 'demo@nomad.app']);
|
||||
expect(isDemoAccountEmail('demo@trek.app')).toBe(true);
|
||||
expect(isDemoAccountEmail('demo@nomad.app')).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for every other address and for none at all', () => {
|
||||
expect(isDemoAccountEmail('admin@trek.app')).toBe(false);
|
||||
expect(isDemoAccountEmail('DEMO@trek.app')).toBe(false);
|
||||
expect(isDemoAccountEmail('')).toBe(false);
|
||||
expect(isDemoAccountEmail(null)).toBe(false);
|
||||
expect(isDemoAccountEmail(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* The shared account every visitor of a demo instance signs in as.
|
||||
*
|
||||
* Both sides ask the same question: the server refuses the writes this
|
||||
* account may not make, and the client shows the same sections under a demo
|
||||
* lock. `demo@nomad.app` is the address the account had before the rename, so
|
||||
* an instance upgraded in place still treats it as the demo account.
|
||||
*/
|
||||
export const DEMO_ACCOUNT_EMAILS: readonly string[] = ['demo@trek.app', 'demo@nomad.app'];
|
||||
|
||||
/** Whether this address is the demo account's. Instance demo mode is asked apart. */
|
||||
export function isDemoAccountEmail(email: string | null | undefined): boolean {
|
||||
return !!email && DEMO_ACCOUNT_EMAILS.includes(email);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const demo: TranslationStrings = {
|
||||
// The welcome dialog a demo instance opens with (components/Demo/DemoWelcome).
|
||||
'demo.welcome.eyebrow': 'عرض تجريبي مباشر',
|
||||
'demo.welcome.title': 'مرحبًا بك في TREK',
|
||||
'demo.welcome.tagline':
|
||||
'مخطط الرحلات الذي تتشاركه: خططوا معًا على الخريطة في الوقت الفعلي، من أول فكرة حتى ألبوم الصور بعد الرحلة.',
|
||||
'demo.welcome.resetTitle': 'يُعاد ضبط كل شيء كل ساعة',
|
||||
'demo.welcome.resetBody':
|
||||
'استكشف وعدّل وأنشئ كما تشاء. عند بداية كل ساعة يعود العرض التجريبي إلى حالته الأولى للزائر التالي.',
|
||||
'demo.welcome.resetIn': 'إعادة الضبط التالية بعد',
|
||||
'demo.welcome.tryTitle': 'جرّبه الآن',
|
||||
'demo.feature.planner.title': 'مخطط الرحلات',
|
||||
'demo.feature.planner.body':
|
||||
'الأيام والأماكن والمسارات على الخريطة، مع الرحلات الجوية والفنادق والمطاعم والحجوزات والتكاليف المشتركة.',
|
||||
'demo.feature.tours.title': 'الجولات والمشي',
|
||||
'demo.feature.tours.body': 'خطط لمسار مشي على شبكة الدروب وتابعه فوق تضاريس ثلاثية الأبعاد مع مخطط الارتفاع.',
|
||||
'demo.feature.studio.title': 'ألبومات الصور في TREK Studio',
|
||||
'demo.feature.studio.body': 'حوّل رحلتك إلى ألبوم صور مصمم، صفحة بعد صفحة.',
|
||||
'demo.feature.collab.title': 'خططوا معًا مباشرة',
|
||||
'demo.feature.collab.body': 'يرى كل من في الرحلة كل تغيير لحظة حدوثه، مع الدردشة والملاحظات والاستطلاعات.',
|
||||
'demo.feature.journey.title': 'مذكرات السفر Journey',
|
||||
'demo.feature.journey.body': 'تدوينات وصور ومزاج وإيجابيات وسلبيات لكل يوم على الطريق.',
|
||||
'demo.feature.atlas.title': 'خريطة العالم Atlas',
|
||||
'demo.feature.atlas.body': 'الدول والمناطق التي زرتها، مع إحصاءات سفرك.',
|
||||
'demo.feature.vacay.title': 'مخطط الإجازات Vacay',
|
||||
'demo.feature.vacay.body': 'خطط لأيام إجازتك حول العطل الرسمية واعرف متى يكون الجميع متفرغين.',
|
||||
'demo.feature.collections.title': 'المجموعات',
|
||||
'demo.feature.collections.body': 'احفظ أماكن من رحلات مختلفة في قوائمك الخاصة وانسخها إلى أي خطة.',
|
||||
'demo.feature.offline.title': 'يعمل دون اتصال',
|
||||
'demo.feature.offline.body': 'ثبّت TREK كتطبيق واحتفظ بخططك وخرائطك وقوائمك دون اتصال.',
|
||||
'demo.welcome.limitsTitle': 'غير متاح في العرض التجريبي',
|
||||
'demo.limit.uploads': 'رفع الصور والمستندات وصور الغلاف',
|
||||
'demo.limit.account': 'تغيير الحساب أو كلمة المرور أو 2FA',
|
||||
'demo.limit.apiKeys': 'إنشاء مفاتيح API',
|
||||
'demo.limit.mcp': 'ربط عملاء MCP',
|
||||
'demo.limit.ai': 'ربط نموذج ذكاء اصطناعي خاص بك',
|
||||
'demo.welcome.fullTitle': 'تضيف النسخة الكاملة',
|
||||
'demo.full.sso': 'تسجيل الدخول عبر OIDC و SSO',
|
||||
'demo.full.backups': 'نسخ احتياطية تلقائية',
|
||||
'demo.full.plugins': 'إضافات لميزات جديدة',
|
||||
'demo.full.assistant': 'مساعد الذكاء الاصطناعي بنموذجك الخاص',
|
||||
'demo.full.admin': 'لوحة إدارة للمستخدمين والإضافات والصلاحيات',
|
||||
'demo.welcome.repo': 'الشيفرة المصدرية على GitHub',
|
||||
'demo.welcome.start': 'ابدأ الاستكشاف',
|
||||
'demo.welcome.credits': 'حقوق الصور',
|
||||
// The photo credits, the welcome dialog's second view (components/Demo/DemoCredits).
|
||||
'demo.credits.title': 'حقوق الصور',
|
||||
'demo.credits.intro':
|
||||
'صور هذا العرض التجريبي من ويكيميديا كومنز، ولكل صورة الترخيص المذكور معها. ينطبق هذا الترخيص على الصورة وحدها، لا على TREK ولا على بقية العرض التجريبي.',
|
||||
'demo.credits.by': 'تصوير {author}',
|
||||
'demo.credits.publicDomain': 'ملكية عامة',
|
||||
'demo.credits.back': 'رجوع',
|
||||
// The lock over a section the shared demo account may not change (components/Demo/DemoLock).
|
||||
'demo.lock.badge': 'الوضع التجريبي',
|
||||
'demo.lock.apiKeys': 'لا يمكن إنشاء مفاتيح API في العرض التجريبي.',
|
||||
'demo.lock.mcp': 'لا يمكن ربط عملاء MCP في العرض التجريبي.',
|
||||
'demo.lock.ai': 'لا يمكن إضافة اتصال ذكاء اصطناعي خاص بك في العرض التجريبي.',
|
||||
'demo.lock.aiTitle': 'اربط نموذج ذكاء اصطناعي خاصًا بك',
|
||||
'demo.lock.account': 'لا يمكن تغيير حساب العرض التجريبي المشترك.',
|
||||
'demo.lock.language': 'يعمل العرض التجريبي دائمًا بالإنجليزية.',
|
||||
};
|
||||
|
||||
export default demo;
|
||||
@@ -27,6 +27,7 @@ import dashboard from './dashboard';
|
||||
import dawarich from './dawarich';
|
||||
import day from './day';
|
||||
import dayplan from './dayplan';
|
||||
import demo from './demo';
|
||||
import docsync from './docsync';
|
||||
import ferratas from './ferratas';
|
||||
import files from './files';
|
||||
@@ -118,6 +119,7 @@ const locale = {
|
||||
...pdf,
|
||||
...planner,
|
||||
...day,
|
||||
...demo,
|
||||
...memories,
|
||||
...collab,
|
||||
...perm,
|
||||
|
||||
@@ -302,7 +302,6 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.disable': 'تعطيل 2FA',
|
||||
'settings.mfa.toastEnabled': 'تم تفعيل المصادقة الثنائية',
|
||||
'settings.mfa.toastDisabled': 'تم تعطيل المصادقة الثنائية',
|
||||
'settings.mfa.demoBlocked': 'غير متاح في الوضع التجريبي',
|
||||
'settings.tabs.offline': 'دون اتصال',
|
||||
'settings.notificationPreferences.email': 'البريد الإلكتروني',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const demo: TranslationStrings = {
|
||||
// The welcome dialog a demo instance opens with (components/Demo/DemoWelcome).
|
||||
'demo.welcome.eyebrow': 'Canlı demo',
|
||||
'demo.welcome.title': 'TREK-ə xoş gəlmisiniz',
|
||||
'demo.welcome.tagline':
|
||||
'Paylaşılan səyahət planlayıcısı: xəritədə real vaxtda birlikdə planlayın, ilk ideyadan səfərdən sonrakı foto kitaba qədər.',
|
||||
'demo.welcome.resetTitle': 'Hər saat hər şey sıfırlanır',
|
||||
'demo.welcome.resetBody':
|
||||
'İstədiyiniz qədər baxın, redaktə edin və yaradın. Hər tam saatda demo növbəti ziyarətçi üçün ilkin vəziyyətinə qayıdır.',
|
||||
'demo.welcome.resetIn': 'Növbəti sıfırlamaya qalıb',
|
||||
'demo.welcome.tryTitle': 'İndi sınayın',
|
||||
'demo.feature.planner.title': 'Səyahət planlayıcısı',
|
||||
'demo.feature.planner.body':
|
||||
'Xəritədə günlər, yerlər və marşrutlar; uçuşlar, otellər, restoranlar, rezervasiyalar və ortaq xərclərlə.',
|
||||
'demo.feature.tours.title': 'Turlar və gəzintilər',
|
||||
'demo.feature.tours.body': 'Cığır şəbəkəsində gəzinti planlayın və hündürlük profili ilə 3D relyefdə izləyin.',
|
||||
'demo.feature.studio.title': 'TREK Studio ilə foto kitablar',
|
||||
'demo.feature.studio.body': 'Səyahəti səhifə səhifə tərtib olunmuş foto kitaba çevirin.',
|
||||
'demo.feature.collab.title': 'Canlı birlikdə planlayın',
|
||||
'demo.feature.collab.body': 'Səfərdəki hər kəs hər dəyişikliyi dərhal görür; söhbət, qeydlər və sorğularla.',
|
||||
'demo.feature.journey.title': 'Journey səyahət gündəliyi',
|
||||
'demo.feature.journey.body': 'Yolda keçən hər gün üçün qeydlər, fotolar, əhval, üstünlüklər və çatışmazlıqlar.',
|
||||
'demo.feature.atlas.title': 'Atlas dünya xəritəsi',
|
||||
'demo.feature.atlas.body': 'Səyahət statistikanızla birlikdə ziyarət etdiyiniz ölkələr və bölgələr.',
|
||||
'demo.feature.vacay.title': 'Vacay məzuniyyət planlayıcısı',
|
||||
'demo.feature.vacay.body': 'İstirahət günlərinizi bayramlara görə planlayın və hamının nə vaxt boş olduğunu görün.',
|
||||
'demo.feature.collections.title': 'Kolleksiyalar',
|
||||
'demo.feature.collections.body':
|
||||
'Müxtəlif səfərlərdən yerləri öz siyahılarınıza yadda saxlayın və istənilən plana köçürün.',
|
||||
'demo.feature.offline.title': 'Oflayn işləyir',
|
||||
'demo.feature.offline.body':
|
||||
'TREK-i tətbiq kimi quraşdırın; planlar, xəritələr və siyahılar bağlantı olmadan da əlinizdə olsun.',
|
||||
'demo.welcome.limitsTitle': 'Demoya daxil deyil',
|
||||
'demo.limit.uploads': 'Foto, sənəd və üz qabığı yükləmək',
|
||||
'demo.limit.account': 'Hesabı, parolu və ya 2FA ayarını dəyişmək',
|
||||
'demo.limit.apiKeys': 'API açarları yaratmaq',
|
||||
'demo.limit.mcp': 'MCP müştərilərini qoşmaq',
|
||||
'demo.limit.ai': 'Öz süni intellekt modelinizi qoşmaq',
|
||||
'demo.welcome.fullTitle': 'Tam versiyada əlavə olaraq',
|
||||
'demo.full.sso': 'OIDC və SSO ilə giriş',
|
||||
'demo.full.backups': 'Avtomatik ehtiyat nüsxələr',
|
||||
'demo.full.plugins': 'Yeni imkanlar üçün plaginlər',
|
||||
'demo.full.assistant': 'Öz modelinizlə süni intellekt köməkçisi',
|
||||
'demo.full.admin': 'İstifadəçilər, əlavələr və icazələr üçün idarəetmə paneli',
|
||||
'demo.welcome.repo': 'GitHub-da mənbə kodu',
|
||||
'demo.welcome.start': 'Kəşf etməyə başlayın',
|
||||
'demo.welcome.credits': 'Foto müəllifləri',
|
||||
// The photo credits, the welcome dialog's second view (components/Demo/DemoCredits).
|
||||
'demo.credits.title': 'Foto müəllifləri',
|
||||
'demo.credits.intro':
|
||||
'Bu demodakı fotolar Wikimedia Commons-dan götürülüb və hər biri yanında göstərilən lisenziya ilə yayımlanır. Bu lisenziya yalnız fotoya aiddir, TREK-ə və ya demonun qalan hissəsinə aid deyil.',
|
||||
'demo.credits.by': 'Müəllif: {author}',
|
||||
'demo.credits.publicDomain': 'İctimai mülkiyyət',
|
||||
'demo.credits.back': 'Geri',
|
||||
// The lock over a section the shared demo account may not change (components/Demo/DemoLock).
|
||||
'demo.lock.badge': 'Demo rejimi',
|
||||
'demo.lock.apiKeys': 'Demoda API açarları yaratmaq olmur.',
|
||||
'demo.lock.mcp': 'Demoda MCP müştərilərini qoşmaq olmur.',
|
||||
'demo.lock.ai': 'Demoda öz süni intellekt bağlantınızı əlavə etmək olmur.',
|
||||
'demo.lock.aiTitle': 'Öz süni intellekt modelinizi qoşun',
|
||||
'demo.lock.account': 'Ortaq demo hesabı dəyişdirilə bilməz.',
|
||||
'demo.lock.language': 'Demo həmişə ingilis dilində işləyir.',
|
||||
};
|
||||
|
||||
export default demo;
|
||||
@@ -27,6 +27,7 @@ import dashboard from './dashboard';
|
||||
import dawarich from './dawarich';
|
||||
import day from './day';
|
||||
import dayplan from './dayplan';
|
||||
import demo from './demo';
|
||||
import docsync from './docsync';
|
||||
import ferratas from './ferratas';
|
||||
import files from './files';
|
||||
@@ -118,6 +119,7 @@ const locale = {
|
||||
...pdf,
|
||||
...planner,
|
||||
...day,
|
||||
...demo,
|
||||
...memories,
|
||||
...collab,
|
||||
...perm,
|
||||
|
||||
@@ -396,7 +396,6 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.disable': '2FA-nı deaktiv et',
|
||||
'settings.mfa.toastEnabled': 'İki mərhələli autentifikasiya aktivləşdirildi',
|
||||
'settings.mfa.toastDisabled': 'İki mərhələli autentifikasiya deaktiv edildi',
|
||||
'settings.mfa.demoBlocked': 'Demo rejimində əlçatan deyil',
|
||||
|
||||
'settings.currency': 'Göstərilən valyuta',
|
||||
'settings.currencyHint':
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const demo: TranslationStrings = {
|
||||
// The welcome dialog a demo instance opens with (components/Demo/DemoWelcome).
|
||||
'demo.welcome.eyebrow': 'Demo ao vivo',
|
||||
'demo.welcome.title': 'Boas vindas ao TREK',
|
||||
'demo.welcome.tagline':
|
||||
'O planejador de viagens para compartilhar: planejem juntos no mapa em tempo real, da primeira ideia ao fotolivro depois da viagem.',
|
||||
'demo.welcome.resetTitle': 'Tudo é redefinido a cada hora',
|
||||
'demo.welcome.resetBody':
|
||||
'Explore, edite e crie o quanto quiser. A cada hora cheia a demo volta ao estado inicial para o próximo visitante.',
|
||||
'demo.welcome.resetIn': 'Próxima redefinição em',
|
||||
'demo.welcome.tryTitle': 'Experimente agora mesmo',
|
||||
'demo.feature.planner.title': 'Planejador de viagens',
|
||||
'demo.feature.planner.body':
|
||||
'Dias, lugares e rotas no mapa, com voos, hotéis, restaurantes, reservas e custos divididos.',
|
||||
'demo.feature.tours.title': 'Passeios e trilhas',
|
||||
'demo.feature.tours.body':
|
||||
'Planeje uma trilha na rede de caminhos e siga a rota sobre o relevo em 3D com o perfil de altitude.',
|
||||
'demo.feature.studio.title': 'Fotolivros com o TREK Studio',
|
||||
'demo.feature.studio.body': 'Transforme uma viagem em um fotolivro diagramado, página por página.',
|
||||
'demo.feature.collab.title': 'Planejar juntos ao vivo',
|
||||
'demo.feature.collab.body': 'Todos na viagem veem cada mudança na hora, com chat, notas e enquetes.',
|
||||
'demo.feature.journey.title': 'Diário de viagem Journey',
|
||||
'demo.feature.journey.body': 'Registros, fotos, humores, prós e contras de cada dia na estrada.',
|
||||
'demo.feature.atlas.title': 'Mapa mundi Atlas',
|
||||
'demo.feature.atlas.body': 'Os países e regiões que você visitou, com suas estatísticas de viagem.',
|
||||
'demo.feature.vacay.title': 'Planejador de férias Vacay',
|
||||
'demo.feature.vacay.body': 'Planeje suas folgas em torno dos feriados e veja quando todos estão livres.',
|
||||
'demo.feature.collections.title': 'Coleções',
|
||||
'demo.feature.collections.body': 'Salve lugares de várias viagens em listas suas e copie para qualquer plano.',
|
||||
'demo.feature.offline.title': 'Funciona offline',
|
||||
'demo.feature.offline.body': 'Instale o TREK como app e mantenha planos, mapas e listas sem conexão.',
|
||||
'demo.welcome.limitsTitle': 'Fora da demo',
|
||||
'demo.limit.uploads': 'Enviar fotos, documentos e capas',
|
||||
'demo.limit.account': 'Alterar a conta, a senha ou a 2FA',
|
||||
'demo.limit.apiKeys': 'Criar chaves de API',
|
||||
'demo.limit.mcp': 'Conectar clientes MCP',
|
||||
'demo.limit.ai': 'Conectar seu próprio modelo de IA',
|
||||
'demo.welcome.fullTitle': 'A versão completa inclui',
|
||||
'demo.full.sso': 'Login com OIDC e SSO',
|
||||
'demo.full.backups': 'Backups automáticos',
|
||||
'demo.full.plugins': 'Plugins para novos recursos',
|
||||
'demo.full.assistant': 'O assistente de IA com seu próprio modelo',
|
||||
'demo.full.admin': 'Um painel de administração para usuários, complementos e permissões',
|
||||
'demo.welcome.repo': 'Código fonte no GitHub',
|
||||
'demo.welcome.start': 'Começar a explorar',
|
||||
'demo.welcome.credits': 'Créditos das fotos',
|
||||
// The photo credits, the welcome dialog's second view (components/Demo/DemoCredits).
|
||||
'demo.credits.title': 'Créditos das fotos',
|
||||
'demo.credits.intro':
|
||||
'As fotos desta demo vêm do Wikimedia Commons, cada uma sob a licença indicada junto a ela. Essa licença vale apenas para a foto, não para o TREK nem para o resto da demo.',
|
||||
'demo.credits.by': 'por {author}',
|
||||
'demo.credits.publicDomain': 'Domínio público',
|
||||
'demo.credits.back': 'Voltar',
|
||||
// The lock over a section the shared demo account may not change (components/Demo/DemoLock).
|
||||
'demo.lock.badge': 'Modo demonstração',
|
||||
'demo.lock.apiKeys': 'Não é possível criar chaves de API na demo.',
|
||||
'demo.lock.mcp': 'Não é possível conectar clientes MCP na demo.',
|
||||
'demo.lock.ai': 'Não é possível adicionar uma conexão de IA própria na demo.',
|
||||
'demo.lock.aiTitle': 'Conectar seu próprio modelo de IA',
|
||||
'demo.lock.account': 'A conta de demonstração compartilhada não pode ser alterada.',
|
||||
'demo.lock.language': 'A demo sempre funciona em inglês.',
|
||||
};
|
||||
|
||||
export default demo;
|
||||
@@ -27,6 +27,7 @@ import dashboard from './dashboard';
|
||||
import dawarich from './dawarich';
|
||||
import day from './day';
|
||||
import dayplan from './dayplan';
|
||||
import demo from './demo';
|
||||
import docsync from './docsync';
|
||||
import ferratas from './ferratas';
|
||||
import files from './files';
|
||||
@@ -118,6 +119,7 @@ const locale = {
|
||||
...pdf,
|
||||
...planner,
|
||||
...day,
|
||||
...demo,
|
||||
...collab,
|
||||
...memories,
|
||||
...perm,
|
||||
|
||||
@@ -209,7 +209,6 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.disable': 'Desativar 2FA',
|
||||
'settings.mfa.toastEnabled': 'Autenticação em duas etapas ativada',
|
||||
'settings.mfa.toastDisabled': 'Autenticação em duas etapas desativada',
|
||||
'settings.mfa.demoBlocked': 'Indisponível no modo demonstração',
|
||||
'settings.mcp.title': 'Configuração MCP',
|
||||
'settings.mcp.endpoint': 'Endpoint MCP',
|
||||
'settings.mcp.clientConfig': 'Configuração do cliente',
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const demo: TranslationStrings = {
|
||||
// The welcome dialog a demo instance opens with (components/Demo/DemoWelcome).
|
||||
'demo.welcome.eyebrow': 'Demo en directe',
|
||||
'demo.welcome.title': 'Benvingut a TREK',
|
||||
'demo.welcome.tagline':
|
||||
'El planificador de viatges per compartir: planifiqueu junts al mapa en temps real, des de la primera idea fins al fotollibre després del viatge.',
|
||||
'demo.welcome.resetTitle': 'Tot es restableix cada hora',
|
||||
'demo.welcome.resetBody':
|
||||
'Explora, edita i crea tant com vulguis. A cada hora en punt la demo torna al seu estat inicial per al visitant següent.',
|
||||
'demo.welcome.resetIn': 'Proper restabliment en',
|
||||
'demo.welcome.tryTitle': 'Prova-ho ara mateix',
|
||||
'demo.feature.planner.title': 'Planificador de viatges',
|
||||
'demo.feature.planner.body':
|
||||
'Dies, llocs i rutes al mapa, amb vols, hotels, restaurants, reserves i despeses compartides.',
|
||||
'demo.feature.tours.title': 'Rutes i excursions',
|
||||
'demo.feature.tours.body':
|
||||
'Planifica una excursió per la xarxa de camins i segueix-la sobre el relleu 3D amb el perfil d’altitud.',
|
||||
'demo.feature.studio.title': 'Fotollibres amb TREK Studio',
|
||||
'demo.feature.studio.body': 'Converteix un viatge en un fotollibre dissenyat, pàgina a pàgina.',
|
||||
'demo.feature.collab.title': 'Planificar junts en directe',
|
||||
'demo.feature.collab.body': 'Tothom al viatge veu cada canvi a l’instant, amb xat, notes i enquestes.',
|
||||
'demo.feature.journey.title': 'Diari de viatge Journey',
|
||||
'demo.feature.journey.body': 'Entrades, fotos, estats d’ànim, pros i contres de cada dia de viatge.',
|
||||
'demo.feature.atlas.title': 'Mapa del món Atlas',
|
||||
'demo.feature.atlas.body': 'Els països i les regions que has visitat, amb les teves estadístiques de viatge.',
|
||||
'demo.feature.vacay.title': 'Planificador de vacances Vacay',
|
||||
'demo.feature.vacay.body': 'Planifica els dies lliures al voltant dels festius i mira quan està lliure tothom.',
|
||||
'demo.feature.collections.title': 'Col·leccions',
|
||||
'demo.feature.collections.body': 'Desa llocs de diferents viatges en llistes pròpies i copia’ls a qualsevol pla.',
|
||||
'demo.feature.offline.title': 'Funciona sense connexió',
|
||||
'demo.feature.offline.body': 'Instal·la TREK com a app i conserva plans, mapes i llistes sense connexió.',
|
||||
'demo.welcome.limitsTitle': 'Fora de la demo',
|
||||
'demo.limit.uploads': 'Pujar fotos, documents i portades',
|
||||
'demo.limit.account': 'Canviar el compte, la contrasenya o la 2FA',
|
||||
'demo.limit.apiKeys': 'Crear claus d’API',
|
||||
'demo.limit.mcp': 'Connectar clients MCP',
|
||||
'demo.limit.ai': 'Connectar el teu propi model d’IA',
|
||||
'demo.welcome.fullTitle': 'La versió completa afegeix',
|
||||
'demo.full.sso': 'Inici de sessió amb OIDC i SSO',
|
||||
'demo.full.backups': 'Còpies de seguretat automàtiques',
|
||||
'demo.full.plugins': 'Connectors per a funcions noves',
|
||||
'demo.full.assistant': 'L’assistent d’IA amb el teu propi model',
|
||||
'demo.full.admin': 'Un tauler d’administració per a usuaris, complements i permisos',
|
||||
'demo.welcome.repo': 'Codi font a GitHub',
|
||||
'demo.welcome.start': 'Comença a explorar',
|
||||
'demo.welcome.credits': 'Crèdits de les fotos',
|
||||
// The photo credits, the welcome dialog's second view (components/Demo/DemoCredits).
|
||||
'demo.credits.title': 'Crèdits de les fotos',
|
||||
'demo.credits.intro':
|
||||
'Les fotos d’aquesta demo provenen de Wikimedia Commons, cadascuna amb la llicència que s’hi indica. Aquesta llicència només s’aplica a la foto, no a TREK ni a la resta de la demo.',
|
||||
'demo.credits.by': 'per {author}',
|
||||
'demo.credits.publicDomain': 'Domini públic',
|
||||
'demo.credits.back': 'Enrere',
|
||||
// The lock over a section the shared demo account may not change (components/Demo/DemoLock).
|
||||
'demo.lock.badge': 'Mode demo',
|
||||
'demo.lock.apiKeys': 'A la demo no es poden crear claus d’API.',
|
||||
'demo.lock.mcp': 'A la demo no es poden connectar clients MCP.',
|
||||
'demo.lock.ai': 'A la demo no es pot afegir una connexió d’IA pròpia.',
|
||||
'demo.lock.aiTitle': 'Connecta el teu propi model d’IA',
|
||||
'demo.lock.account': 'El compte de demo compartit no es pot modificar.',
|
||||
'demo.lock.language': 'La demo sempre funciona en anglès.',
|
||||
};
|
||||
|
||||
export default demo;
|
||||
@@ -27,6 +27,7 @@ import dashboard from './dashboard';
|
||||
import dawarich from './dawarich';
|
||||
import day from './day';
|
||||
import dayplan from './dayplan';
|
||||
import demo from './demo';
|
||||
import docsync from './docsync';
|
||||
import ferratas from './ferratas';
|
||||
import files from './files';
|
||||
@@ -117,6 +118,7 @@ const locale = {
|
||||
...pdf,
|
||||
...planner,
|
||||
...day,
|
||||
...demo,
|
||||
...memories,
|
||||
...collab,
|
||||
...airport,
|
||||
|
||||
@@ -259,7 +259,6 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.disable': 'Desactiva el 2FA',
|
||||
'settings.mfa.toastEnabled': 'Autenticació de dos factors activada',
|
||||
'settings.mfa.toastDisabled': 'Autenticació de dos factors desactivada',
|
||||
'settings.mfa.demoBlocked': 'No disponible en mode de demostració',
|
||||
'settings.toast.mapSaved': 'Configuració del mapa desada',
|
||||
'settings.toast.keysSaved': 'Claus API desades',
|
||||
'settings.toast.displaySaved': 'Configuració de visualització desada',
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const demo: TranslationStrings = {
|
||||
// The welcome dialog a demo instance opens with (components/Demo/DemoWelcome).
|
||||
'demo.welcome.eyebrow': 'Živé demo',
|
||||
'demo.welcome.title': 'Vítejte v TREK',
|
||||
'demo.welcome.tagline':
|
||||
'Plánovač cest ke sdílení: plánujte spolu na mapě v reálném čase, od prvního nápadu až po fotoknihu po cestě.',
|
||||
'demo.welcome.resetTitle': 'Každou hodinu se vše obnoví',
|
||||
'demo.welcome.resetBody':
|
||||
'Prohlížejte, upravujte a vytvářejte, co chcete. Každou celou hodinu se demo vrátí do výchozího stavu pro dalšího návštěvníka.',
|
||||
'demo.welcome.resetIn': 'Další obnovení za',
|
||||
'demo.welcome.tryTitle': 'Vyzkoušejte hned',
|
||||
'demo.feature.planner.title': 'Plánovač cest',
|
||||
'demo.feature.planner.body':
|
||||
'Dny, místa a trasy na mapě, s lety, hotely, restauracemi, rezervacemi a sdílenými náklady.',
|
||||
'demo.feature.tours.title': 'Túry a výlety',
|
||||
'demo.feature.tours.body': 'Naplánujte túru po síti stezek a sledujte ji nad 3D terénem s výškovým profilem.',
|
||||
'demo.feature.studio.title': 'Fotoknihy v TREK Studio',
|
||||
'demo.feature.studio.body': 'Proměňte cestu stránku po stránce v navrženou fotoknihu.',
|
||||
'demo.feature.collab.title': 'Plánování spolu naživo',
|
||||
'demo.feature.collab.body': 'Všichni v cestě vidí každou změnu okamžitě, s chatem, poznámkami a anketami.',
|
||||
'demo.feature.journey.title': 'Cestovní deník Journey',
|
||||
'demo.feature.journey.body': 'Zápisy, fotky, nálady, klady a zápory každého dne na cestě.',
|
||||
'demo.feature.atlas.title': 'Mapa světa Atlas',
|
||||
'demo.feature.atlas.body': 'Země a regiony, které jste navštívili, s vaší cestovní statistikou.',
|
||||
'demo.feature.vacay.title': 'Plánovač dovolené Vacay',
|
||||
'demo.feature.vacay.body': 'Plánujte volno kolem svátků a uvidíte, kdy mají všichni čas.',
|
||||
'demo.feature.collections.title': 'Kolekce',
|
||||
'demo.feature.collections.body':
|
||||
'Ukládejte místa z různých cest do vlastních seznamů a kopírujte je do libovolného plánu.',
|
||||
'demo.feature.offline.title': 'Funguje offline',
|
||||
'demo.feature.offline.body': 'Nainstalujte si TREK jako aplikaci a mějte plány, mapy a seznamy i bez připojení.',
|
||||
'demo.welcome.limitsTitle': 'Není součástí dema',
|
||||
'demo.limit.uploads': 'Nahrávání fotek, dokumentů a obálek',
|
||||
'demo.limit.account': 'Změna účtu, hesla nebo 2FA',
|
||||
'demo.limit.apiKeys': 'Vytváření klíčů API',
|
||||
'demo.limit.mcp': 'Připojení klientů MCP',
|
||||
'demo.limit.ai': 'Připojení vlastního modelu AI',
|
||||
'demo.welcome.fullTitle': 'Plná verze navíc nabízí',
|
||||
'demo.full.sso': 'Přihlášení přes OIDC a SSO',
|
||||
'demo.full.backups': 'Automatické zálohy',
|
||||
'demo.full.plugins': 'Pluginy pro nové funkce',
|
||||
'demo.full.assistant': 'Asistenta AI s vlastním modelem',
|
||||
'demo.full.admin': 'Administraci uživatelů, doplňků a oprávnění',
|
||||
'demo.welcome.repo': 'Zdrojový kód na GitHubu',
|
||||
'demo.welcome.start': 'Začít objevovat',
|
||||
'demo.welcome.credits': 'Autoři fotografií',
|
||||
// The photo credits, the welcome dialog's second view (components/Demo/DemoCredits).
|
||||
'demo.credits.title': 'Autoři fotografií',
|
||||
'demo.credits.intro':
|
||||
'Fotografie v tomto demu pocházejí z Wikimedia Commons, každá pod licencí, která je u ní uvedena. Tato licence platí jen pro fotografii, ne pro TREK ani pro zbytek dema.',
|
||||
'demo.credits.by': 'Autor: {author}',
|
||||
'demo.credits.publicDomain': 'Volné dílo',
|
||||
'demo.credits.back': 'Zpět',
|
||||
// The lock over a section the shared demo account may not change (components/Demo/DemoLock).
|
||||
'demo.lock.badge': 'Režim dema',
|
||||
'demo.lock.apiKeys': 'V demu nelze vytvářet klíče API.',
|
||||
'demo.lock.mcp': 'V demu nelze připojit klienty MCP.',
|
||||
'demo.lock.ai': 'V demu nelze přidat vlastní připojení AI.',
|
||||
'demo.lock.aiTitle': 'Připojit vlastní model AI',
|
||||
'demo.lock.account': 'Sdílený demo účet nelze změnit.',
|
||||
'demo.lock.language': 'Demo běží vždy v angličtině.',
|
||||
};
|
||||
|
||||
export default demo;
|
||||
@@ -27,6 +27,7 @@ import dashboard from './dashboard';
|
||||
import dawarich from './dawarich';
|
||||
import day from './day';
|
||||
import dayplan from './dayplan';
|
||||
import demo from './demo';
|
||||
import docsync from './docsync';
|
||||
import ferratas from './ferratas';
|
||||
import files from './files';
|
||||
@@ -118,6 +119,7 @@ const locale = {
|
||||
...pdf,
|
||||
...planner,
|
||||
...day,
|
||||
...demo,
|
||||
...memories,
|
||||
...collab,
|
||||
...perm,
|
||||
|
||||
@@ -289,7 +289,6 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.disable': 'Vypnout 2FA',
|
||||
'settings.mfa.toastEnabled': 'Dvoufaktorové ověření bylo zapnuto',
|
||||
'settings.mfa.toastDisabled': 'Dvoufaktorové ověření bylo vypnuto',
|
||||
'settings.mfa.demoBlocked': 'Není k dispozici v demo režimu',
|
||||
'settings.bookingLabels': 'Popisky tras rezervací',
|
||||
'settings.bookingLabelsHint': 'Zobrazuje názvy stanic / letišť na mapě. Pokud je vypnuto, zobrazí se pouze ikona.',
|
||||
'settings.mustChangePassword': 'Před pokračováním musíte změnit heslo.',
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const demo: TranslationStrings = {
|
||||
// The welcome dialog a demo instance opens with (components/Demo/DemoWelcome).
|
||||
'demo.welcome.eyebrow': 'Demo zum Ausprobieren',
|
||||
'demo.welcome.title': 'Willkommen bei TREK',
|
||||
'demo.welcome.tagline':
|
||||
'Der Reiseplaner zum Teilen: gemeinsam und in Echtzeit auf der Karte planen, von der ersten Idee bis zum Fotobuch nach der Reise.',
|
||||
'demo.welcome.resetTitle': 'Jede Stunde wird alles zurückgesetzt',
|
||||
'demo.welcome.resetBody':
|
||||
'Schau dich um, bearbeite und erstelle so viel du willst. Zur vollen Stunde kehrt die Demo für die nächsten Besucher in ihren Ausgangszustand zurück.',
|
||||
'demo.welcome.resetIn': 'Nächster Reset in',
|
||||
'demo.welcome.tryTitle': 'Gleich ausprobieren',
|
||||
'demo.feature.planner.title': 'Reiseplaner',
|
||||
'demo.feature.planner.body':
|
||||
'Tage, Orte und Routen auf der Karte, mit Flügen, Hotels, Restaurants, Buchungen und geteilten Kosten.',
|
||||
'demo.feature.tours.title': 'Touren und Wanderungen',
|
||||
'demo.feature.tours.body': 'Plane eine Wanderung auf dem Wegenetz und folge ihr über 3D-Gelände mit Höhenprofil.',
|
||||
'demo.feature.studio.title': 'Fotobücher mit TREK Studio',
|
||||
'demo.feature.studio.body': 'Mach aus einer Reise Seite für Seite ein gestaltetes Fotobuch.',
|
||||
'demo.feature.collab.title': 'Live gemeinsam planen',
|
||||
'demo.feature.collab.body': 'Alle in der Reise sehen jede Änderung sofort, mit Chat, Notizen und Umfragen.',
|
||||
'demo.feature.journey.title': 'Reisetagebuch Journey',
|
||||
'demo.feature.journey.body': 'Einträge, Fotos, Stimmungen, Pro und Contra für jeden Tag unterwegs.',
|
||||
'demo.feature.atlas.title': 'Weltkarte Atlas',
|
||||
'demo.feature.atlas.body': 'Die Länder und Regionen, in denen du warst, mit deiner Reisestatistik.',
|
||||
'demo.feature.vacay.title': 'Urlaubsplaner Vacay',
|
||||
'demo.feature.vacay.body': 'Plane freie Tage rund um Feiertage und sieh, wann alle Zeit haben.',
|
||||
'demo.feature.collections.title': 'Sammlungen',
|
||||
'demo.feature.collections.body':
|
||||
'Speichere Orte über Reisen hinweg in eigenen Listen und übernimm sie in jeden Plan.',
|
||||
'demo.feature.offline.title': 'Funktioniert offline',
|
||||
'demo.feature.offline.body': 'Installiere TREK als App und behalte Pläne, Karten und Listen auch ohne Verbindung.',
|
||||
'demo.welcome.limitsTitle': 'Nicht Teil der Demo',
|
||||
'demo.limit.uploads': 'Fotos, Dokumente und Titelbilder hochladen',
|
||||
'demo.limit.account': 'Konto, Passwort oder 2FA ändern',
|
||||
'demo.limit.apiKeys': 'API-Schlüssel erstellen',
|
||||
'demo.limit.mcp': 'MCP-Clients verbinden',
|
||||
'demo.limit.ai': 'Ein eigenes KI-Modell verbinden',
|
||||
'demo.welcome.fullTitle': 'Die Vollversion bietet zusätzlich',
|
||||
'demo.full.sso': 'Anmeldung mit OIDC und SSO',
|
||||
'demo.full.backups': 'Automatische Backups',
|
||||
'demo.full.plugins': 'Plugins für neue Funktionen',
|
||||
'demo.full.assistant': 'Den KI-Assistenten mit deinem eigenen Modell',
|
||||
'demo.full.admin': 'Einen Adminbereich für Nutzer, Addons und Berechtigungen',
|
||||
'demo.welcome.repo': 'Quellcode auf GitHub',
|
||||
'demo.welcome.start': 'Los geht’s',
|
||||
'demo.welcome.credits': 'Bildnachweise',
|
||||
// The photo credits, the welcome dialog's second view (components/Demo/DemoCredits).
|
||||
'demo.credits.title': 'Bildnachweise',
|
||||
'demo.credits.intro':
|
||||
'Die Fotos dieser Demo stammen aus Wikimedia Commons, jedes unter der Lizenz, die bei ihm steht. Diese Lizenz gilt nur für das Foto, nicht für TREK oder den Rest der Demo.',
|
||||
'demo.credits.by': 'Foto: {author}',
|
||||
'demo.credits.publicDomain': 'Gemeinfrei',
|
||||
'demo.credits.back': 'Zurück',
|
||||
// The lock over a section the shared demo account may not change (components/Demo/DemoLock).
|
||||
'demo.lock.badge': 'Demomodus',
|
||||
'demo.lock.apiKeys': 'In der Demo lassen sich keine API-Schlüssel erstellen.',
|
||||
'demo.lock.mcp': 'In der Demo lassen sich keine MCP-Clients verbinden.',
|
||||
'demo.lock.ai': 'In der Demo lässt sich keine eigene KI-Verbindung hinzufügen.',
|
||||
'demo.lock.aiTitle': 'Eigenes KI-Modell verbinden',
|
||||
'demo.lock.account': 'Das gemeinsame Demokonto lässt sich nicht ändern.',
|
||||
'demo.lock.language': 'Die Demo läuft immer auf Englisch.',
|
||||
};
|
||||
|
||||
export default demo;
|
||||
@@ -27,6 +27,7 @@ import dashboard from './dashboard';
|
||||
import dawarich from './dawarich';
|
||||
import day from './day';
|
||||
import dayplan from './dayplan';
|
||||
import demo from './demo';
|
||||
import docsync from './docsync';
|
||||
import ferratas from './ferratas';
|
||||
import files from './files';
|
||||
@@ -118,6 +119,7 @@ const locale = {
|
||||
...pdf,
|
||||
...planner,
|
||||
...day,
|
||||
...demo,
|
||||
...memories,
|
||||
...collab,
|
||||
...perm,
|
||||
|
||||
@@ -299,7 +299,6 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.disable': '2FA deaktivieren',
|
||||
'settings.mfa.toastEnabled': 'Zwei-Faktor-Authentifizierung aktiviert',
|
||||
'settings.mfa.toastDisabled': 'Zwei-Faktor-Authentifizierung deaktiviert',
|
||||
'settings.mfa.demoBlocked': 'In der Demo nicht verfügbar',
|
||||
'settings.notifyVersionAvailable': 'Neue Version verfügbar',
|
||||
'settings.notifyReplicaFailure': 'Speicher-Replik-Fehler',
|
||||
'settings.notifyPaddleRegionRequested': 'Paddel-Region angefragt',
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const demo: TranslationStrings = {
|
||||
// The welcome dialog a demo instance opens with (components/Demo/DemoWelcome).
|
||||
'demo.welcome.eyebrow': 'Live demo',
|
||||
'demo.welcome.title': 'Welcome to TREK',
|
||||
'demo.welcome.tagline':
|
||||
'The travel planner you share: plan together on the map in real time, from the first idea to the photo book after the trip.',
|
||||
'demo.welcome.resetTitle': 'Everything resets every hour',
|
||||
'demo.welcome.resetBody':
|
||||
'Explore, edit and create as much as you like. On the hour the demo returns to its starting state for the next visitor.',
|
||||
'demo.welcome.resetIn': 'Next reset in',
|
||||
'demo.welcome.tryTitle': 'Try it right now',
|
||||
'demo.feature.planner.title': 'Trip planner',
|
||||
'demo.feature.planner.body':
|
||||
'Days, places and routes on the map, with flights, hotels, restaurants, bookings and shared costs.',
|
||||
'demo.feature.tours.title': 'Tours and hikes',
|
||||
'demo.feature.tours.body':
|
||||
'Plan a hike along the trail network and follow it over 3D terrain with its elevation profile.',
|
||||
'demo.feature.studio.title': 'Photo books in TREK Studio',
|
||||
'demo.feature.studio.body': 'Turn a journey into a designed photo book, page by page.',
|
||||
'demo.feature.collab.title': 'Plan together live',
|
||||
'demo.feature.collab.body':
|
||||
'Everyone on the trip sees each change the moment it happens, with chat, notes and polls.',
|
||||
'demo.feature.journey.title': 'Journey travel journal',
|
||||
'demo.feature.journey.body': 'Entries, photos, moods, pros and cons for every day on the road.',
|
||||
'demo.feature.atlas.title': 'Atlas world map',
|
||||
'demo.feature.atlas.body': 'The countries and regions you have visited, with your travel statistics.',
|
||||
'demo.feature.vacay.title': 'Vacay holiday planner',
|
||||
'demo.feature.vacay.body': 'Plan your days off around public holidays and see when everyone is free.',
|
||||
'demo.feature.collections.title': 'Collections',
|
||||
'demo.feature.collections.body': 'Save places across trips in lists of your own and copy them into any plan.',
|
||||
'demo.feature.offline.title': 'Works offline',
|
||||
'demo.feature.offline.body': 'Install TREK as an app and keep your plans, maps and lists without a connection.',
|
||||
'demo.welcome.limitsTitle': 'Not part of the demo',
|
||||
'demo.limit.uploads': 'Uploading photos, documents and covers',
|
||||
'demo.limit.account': 'Changing the account, its password or 2FA',
|
||||
'demo.limit.apiKeys': 'Creating API keys',
|
||||
'demo.limit.mcp': 'Connecting MCP clients',
|
||||
'demo.limit.ai': 'Connecting your own AI model',
|
||||
'demo.welcome.fullTitle': 'The full version adds',
|
||||
'demo.full.sso': 'Sign in with OIDC and SSO',
|
||||
'demo.full.backups': 'Automatic backups',
|
||||
'demo.full.plugins': 'Plugins for new features',
|
||||
'demo.full.assistant': 'The AI assistant on your own model',
|
||||
'demo.full.admin': 'An admin panel for users, addons and permissions',
|
||||
'demo.welcome.repo': 'Source code on GitHub',
|
||||
'demo.welcome.start': 'Start exploring',
|
||||
'demo.welcome.credits': 'Photo credits',
|
||||
// The photo credits, the welcome dialog's second view (components/Demo/DemoCredits).
|
||||
'demo.credits.title': 'Photo credits',
|
||||
'demo.credits.intro':
|
||||
'The photos in this demo come from Wikimedia Commons, each under the license named with it. That license applies to the photo only, not to TREK or the rest of the demo.',
|
||||
'demo.credits.by': 'by {author}',
|
||||
'demo.credits.publicDomain': 'Public domain',
|
||||
'demo.credits.back': 'Back',
|
||||
// The lock over a section the shared demo account may not change (components/Demo/DemoLock).
|
||||
'demo.lock.badge': 'Demo mode',
|
||||
'demo.lock.apiKeys': 'API keys cannot be created in the demo.',
|
||||
'demo.lock.mcp': 'MCP clients cannot be connected in the demo.',
|
||||
'demo.lock.ai': 'Your own AI connection cannot be added in the demo.',
|
||||
'demo.lock.aiTitle': 'Connect your own AI model',
|
||||
'demo.lock.account': 'The shared demo account cannot be changed.',
|
||||
'demo.lock.language': 'The demo always runs in English.',
|
||||
};
|
||||
|
||||
export default demo;
|
||||
@@ -27,6 +27,7 @@ import dashboard from './dashboard';
|
||||
import dawarich from './dawarich';
|
||||
import day from './day';
|
||||
import dayplan from './dayplan';
|
||||
import demo from './demo';
|
||||
import docsync from './docsync';
|
||||
import ferratas from './ferratas';
|
||||
import files from './files';
|
||||
@@ -118,6 +119,7 @@ const locale = {
|
||||
...pdf,
|
||||
...planner,
|
||||
...day,
|
||||
...demo,
|
||||
...memories,
|
||||
...collab,
|
||||
...perm,
|
||||
|
||||
@@ -356,7 +356,6 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.disable': 'Disable 2FA',
|
||||
'settings.mfa.toastEnabled': 'Two-factor authentication enabled',
|
||||
'settings.mfa.toastDisabled': 'Two-factor authentication disabled',
|
||||
'settings.mfa.demoBlocked': 'Not available in demo mode',
|
||||
'settings.currency': 'Display currency',
|
||||
'settings.currencyHint':
|
||||
'Amounts in Costs are shown converted to this currency for display only; the original amounts are unchanged.',
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const demo: TranslationStrings = {
|
||||
// The welcome dialog a demo instance opens with (components/Demo/DemoWelcome).
|
||||
'demo.welcome.eyebrow': 'Demo en vivo',
|
||||
'demo.welcome.title': 'Bienvenido a TREK',
|
||||
'demo.welcome.tagline':
|
||||
'El planificador de viajes para compartir: planificad juntos en el mapa en tiempo real, desde la primera idea hasta el fotolibro después del viaje.',
|
||||
'demo.welcome.resetTitle': 'Todo se restablece cada hora',
|
||||
'demo.welcome.resetBody':
|
||||
'Explora, edita y crea todo lo que quieras. A cada hora en punto la demo vuelve a su estado inicial para el siguiente visitante.',
|
||||
'demo.welcome.resetIn': 'Próximo restablecimiento en',
|
||||
'demo.welcome.tryTitle': 'Pruébalo ahora mismo',
|
||||
'demo.feature.planner.title': 'Planificador de viajes',
|
||||
'demo.feature.planner.body':
|
||||
'Días, lugares y rutas en el mapa, con vuelos, hoteles, restaurantes, reservas y gastos compartidos.',
|
||||
'demo.feature.tours.title': 'Rutas y senderismo',
|
||||
'demo.feature.tours.body':
|
||||
'Planifica una ruta por la red de senderos y síguela sobre relieve 3D con su perfil de altitud.',
|
||||
'demo.feature.studio.title': 'Fotolibros con TREK Studio',
|
||||
'demo.feature.studio.body': 'Convierte un viaje en un fotolibro diseñado, página a página.',
|
||||
'demo.feature.collab.title': 'Planificar juntos en directo',
|
||||
'demo.feature.collab.body': 'Todos en el viaje ven cada cambio al instante, con chat, notas y encuestas.',
|
||||
'demo.feature.journey.title': 'Diario de viaje Journey',
|
||||
'demo.feature.journey.body': 'Entradas, fotos, estados de ánimo, pros y contras de cada día de viaje.',
|
||||
'demo.feature.atlas.title': 'Mapa mundial Atlas',
|
||||
'demo.feature.atlas.body': 'Los países y regiones que has visitado, con tus estadísticas de viaje.',
|
||||
'demo.feature.vacay.title': 'Planificador de vacaciones Vacay',
|
||||
'demo.feature.vacay.body': 'Planifica tus días libres en torno a los festivos y mira cuándo están todos libres.',
|
||||
'demo.feature.collections.title': 'Colecciones',
|
||||
'demo.feature.collections.body':
|
||||
'Guarda lugares de distintos viajes en tus propias listas y cópialos a cualquier plan.',
|
||||
'demo.feature.offline.title': 'Funciona sin conexión',
|
||||
'demo.feature.offline.body': 'Instala TREK como app y conserva tus planes, mapas y listas sin conexión.',
|
||||
'demo.welcome.limitsTitle': 'Fuera de la demo',
|
||||
'demo.limit.uploads': 'Subir fotos, documentos y portadas',
|
||||
'demo.limit.account': 'Cambiar la cuenta, su contraseña o la 2FA',
|
||||
'demo.limit.apiKeys': 'Crear claves API',
|
||||
'demo.limit.mcp': 'Conectar clientes MCP',
|
||||
'demo.limit.ai': 'Conectar tu propio modelo de IA',
|
||||
'demo.welcome.fullTitle': 'La versión completa añade',
|
||||
'demo.full.sso': 'Inicio de sesión con OIDC y SSO',
|
||||
'demo.full.backups': 'Copias de seguridad automáticas',
|
||||
'demo.full.plugins': 'Plugins para nuevas funciones',
|
||||
'demo.full.assistant': 'El asistente de IA con tu propio modelo',
|
||||
'demo.full.admin': 'Un panel de administración para usuarios, complementos y permisos',
|
||||
'demo.welcome.repo': 'Código fuente en GitHub',
|
||||
'demo.welcome.start': 'Empezar a explorar',
|
||||
'demo.welcome.credits': 'Créditos de las fotos',
|
||||
// The photo credits, the welcome dialog's second view (components/Demo/DemoCredits).
|
||||
'demo.credits.title': 'Créditos de las fotos',
|
||||
'demo.credits.intro':
|
||||
'Las fotos de esta demo proceden de Wikimedia Commons, cada una con la licencia indicada junto a ella. Esa licencia se aplica solo a la foto, no a TREK ni al resto de la demo.',
|
||||
'demo.credits.by': 'por {author}',
|
||||
'demo.credits.publicDomain': 'Dominio público',
|
||||
'demo.credits.back': 'Volver',
|
||||
// The lock over a section the shared demo account may not change (components/Demo/DemoLock).
|
||||
'demo.lock.badge': 'Modo demo',
|
||||
'demo.lock.apiKeys': 'En la demo no se pueden crear claves API.',
|
||||
'demo.lock.mcp': 'En la demo no se pueden conectar clientes MCP.',
|
||||
'demo.lock.ai': 'En la demo no se puede añadir una conexión de IA propia.',
|
||||
'demo.lock.aiTitle': 'Conectar tu propio modelo de IA',
|
||||
'demo.lock.account': 'La cuenta de demo compartida no se puede modificar.',
|
||||
'demo.lock.language': 'La demo siempre funciona en inglés.',
|
||||
};
|
||||
|
||||
export default demo;
|
||||
@@ -27,6 +27,7 @@ import dashboard from './dashboard';
|
||||
import dawarich from './dawarich';
|
||||
import day from './day';
|
||||
import dayplan from './dayplan';
|
||||
import demo from './demo';
|
||||
import docsync from './docsync';
|
||||
import ferratas from './ferratas';
|
||||
import files from './files';
|
||||
@@ -116,6 +117,7 @@ const locale = {
|
||||
...pdf,
|
||||
...planner,
|
||||
...day,
|
||||
...demo,
|
||||
...memories,
|
||||
...collab,
|
||||
...airport,
|
||||
|
||||
@@ -277,7 +277,6 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.disable': 'Desactivar 2FA',
|
||||
'settings.mfa.toastEnabled': 'Autenticación de dos factores activada',
|
||||
'settings.mfa.toastDisabled': 'Autenticación de dos factores desactivada',
|
||||
'settings.mfa.demoBlocked': 'No disponible en modo demo',
|
||||
'settings.toast.mapSaved': 'Ajustes del mapa guardados',
|
||||
'settings.toast.keysSaved': 'Claves API guardadas',
|
||||
'settings.toast.displaySaved': 'Ajustes de visualización guardados',
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const demo: TranslationStrings = {
|
||||
// The welcome dialog a demo instance opens with (components/Demo/DemoWelcome).
|
||||
'demo.welcome.eyebrow': 'Otsedemo',
|
||||
'demo.welcome.title': 'Tere tulemast TREKi',
|
||||
'demo.welcome.tagline':
|
||||
'Jagatav reisiplaneerija: planeerige koos kaardil reaalajas, esimesest ideest reisijärgse fotoraamatuni.',
|
||||
'demo.welcome.resetTitle': 'Iga tund lähtestatakse kõik',
|
||||
'demo.welcome.resetBody':
|
||||
'Uudista, muuda ja loo nii palju kui soovid. Iga täistunni järel naaseb demo järgmise külastaja jaoks algolekusse.',
|
||||
'demo.welcome.resetIn': 'Järgmine lähtestamine',
|
||||
'demo.welcome.tryTitle': 'Proovi kohe',
|
||||
'demo.feature.planner.title': 'Reisiplaneerija',
|
||||
'demo.feature.planner.body':
|
||||
'Päevad, kohad ja marsruudid kaardil, koos lendude, hotellide, restoranide, broneeringute ja jagatud kuludega.',
|
||||
'demo.feature.tours.title': 'Matkad ja retked',
|
||||
'demo.feature.tours.body': 'Planeeri matk radade võrgustikus ja jälgi seda 3D maastikul koos kõrgusprofiiliga.',
|
||||
'demo.feature.studio.title': 'Fotoraamatud TREK Studios',
|
||||
'demo.feature.studio.body': 'Muuda reis lehekülg lehekülje haaval kujundatud fotoraamatuks.',
|
||||
'demo.feature.collab.title': 'Planeerige koos otse',
|
||||
'demo.feature.collab.body': 'Kõik reisil näevad iga muudatust kohe, koos vestluse, märkmete ja küsitlustega.',
|
||||
'demo.feature.journey.title': 'Reisipäevik Journey',
|
||||
'demo.feature.journey.body': 'Sissekanded, fotod, meeleolud, plussid ja miinused igast reisipäevast.',
|
||||
'demo.feature.atlas.title': 'Maailmakaart Atlas',
|
||||
'demo.feature.atlas.body': 'Riigid ja piirkonnad, kus oled käinud, koos sinu reisistatistikaga.',
|
||||
'demo.feature.vacay.title': 'Puhkuseplaneerija Vacay',
|
||||
'demo.feature.vacay.body': 'Planeeri vabu päevi pühade ümber ja vaata, millal kõigil aega on.',
|
||||
'demo.feature.collections.title': 'Kogumikud',
|
||||
'demo.feature.collections.body': 'Salvesta kohti eri reisidelt oma nimekirjadesse ja kopeeri need mis tahes plaani.',
|
||||
'demo.feature.offline.title': 'Töötab võrguta',
|
||||
'demo.feature.offline.body':
|
||||
'Paigalda TREK rakendusena ja hoia plaanid, kaardid ja nimekirjad käepärast ka ilma ühenduseta.',
|
||||
'demo.welcome.limitsTitle': 'Demos puudub',
|
||||
'demo.limit.uploads': 'Fotode, dokumentide ja kaanepiltide üleslaadimine',
|
||||
'demo.limit.account': 'Konto, parooli või 2FA muutmine',
|
||||
'demo.limit.apiKeys': 'API-võtmete loomine',
|
||||
'demo.limit.mcp': 'MCP-klientide ühendamine',
|
||||
'demo.limit.ai': 'Oma AI-mudeli ühendamine',
|
||||
'demo.welcome.fullTitle': 'Täisversioonis lisaks',
|
||||
'demo.full.sso': 'Sisselogimine OIDC ja SSO abil',
|
||||
'demo.full.backups': 'Automaatsed varukoopiad',
|
||||
'demo.full.plugins': 'Pistikprogrammid uute funktsioonide jaoks',
|
||||
'demo.full.assistant': 'AI-assistent sinu oma mudeliga',
|
||||
'demo.full.admin': 'Halduspaneel kasutajate, lisamoodulite ja õiguste jaoks',
|
||||
'demo.welcome.repo': 'Lähtekood GitHubis',
|
||||
'demo.welcome.start': 'Alusta avastamist',
|
||||
'demo.welcome.credits': 'Fotode autorid',
|
||||
// The photo credits, the welcome dialog's second view (components/Demo/DemoCredits).
|
||||
'demo.credits.title': 'Fotode autorid',
|
||||
'demo.credits.intro':
|
||||
'Selle demo fotod pärinevad Wikimedia Commonsist, igaüks selle juures märgitud litsentsiga. See litsents kehtib ainult foto kohta, mitte TREKi ega ülejäänud demo kohta.',
|
||||
'demo.credits.by': 'Autor: {author}',
|
||||
'demo.credits.publicDomain': 'Avalik omand',
|
||||
'demo.credits.back': 'Tagasi',
|
||||
// The lock over a section the shared demo account may not change (components/Demo/DemoLock).
|
||||
'demo.lock.badge': 'Demorežiim',
|
||||
'demo.lock.apiKeys': 'Demos ei saa API-võtmeid luua.',
|
||||
'demo.lock.mcp': 'Demos ei saa MCP-kliente ühendada.',
|
||||
'demo.lock.ai': 'Demos ei saa oma AI-ühendust lisada.',
|
||||
'demo.lock.aiTitle': 'Ühenda oma AI-mudel',
|
||||
'demo.lock.account': 'Jagatud demokontot ei saa muuta.',
|
||||
'demo.lock.language': 'Demo töötab alati inglise keeles.',
|
||||
};
|
||||
|
||||
export default demo;
|
||||
@@ -27,6 +27,7 @@ import dashboard from './dashboard';
|
||||
import dawarich from './dawarich';
|
||||
import day from './day';
|
||||
import dayplan from './dayplan';
|
||||
import demo from './demo';
|
||||
import docsync from './docsync';
|
||||
import ferratas from './ferratas';
|
||||
import files from './files';
|
||||
@@ -118,6 +119,7 @@ const locale = {
|
||||
...pdf,
|
||||
...planner,
|
||||
...day,
|
||||
...demo,
|
||||
...memories,
|
||||
...collab,
|
||||
...perm,
|
||||
|
||||
@@ -361,7 +361,6 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.disable': 'Keela 2FA',
|
||||
'settings.mfa.toastEnabled': 'Kaheastmeline autentimine lubatud',
|
||||
'settings.mfa.toastDisabled': 'Kaheastmeline autentimine keelatud',
|
||||
'settings.mfa.demoBlocked': 'Demorežiimis pole saadaval',
|
||||
'settings.currency': 'Kuvamisvaluuta',
|
||||
'settings.currencyHint': 'Kulude summad teisendatakse sellesse valuutasse ainult kuvamiseks; algsummad ei muutu.',
|
||||
'settings.currencyTrip': 'Reisi valuuta',
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const demo: TranslationStrings = {
|
||||
// The welcome dialog a demo instance opens with (components/Demo/DemoWelcome).
|
||||
'demo.welcome.eyebrow': 'Démo en direct',
|
||||
'demo.welcome.title': 'Bienvenue sur TREK',
|
||||
'demo.welcome.tagline':
|
||||
'Le planificateur de voyage à partager : planifiez ensemble sur la carte en temps réel, de la première idée au livre photo après le voyage.',
|
||||
'demo.welcome.resetTitle': 'Tout est réinitialisé chaque heure',
|
||||
'demo.welcome.resetBody':
|
||||
'Explorez, modifiez et créez autant que vous voulez. À chaque heure pile, la démo revient à son état initial pour le visiteur suivant.',
|
||||
'demo.welcome.resetIn': 'Prochaine réinitialisation dans',
|
||||
'demo.welcome.tryTitle': 'À essayer tout de suite',
|
||||
'demo.feature.planner.title': 'Planificateur de voyage',
|
||||
'demo.feature.planner.body':
|
||||
'Jours, lieux et itinéraires sur la carte, avec vols, hôtels, restaurants, réservations et frais partagés.',
|
||||
'demo.feature.tours.title': 'Randonnées et circuits',
|
||||
'demo.feature.tours.body':
|
||||
'Tracez une randonnée sur le réseau de sentiers et suivez la en relief 3D avec son profil d’altitude.',
|
||||
'demo.feature.studio.title': 'Livres photo avec TREK Studio',
|
||||
'demo.feature.studio.body': 'Transformez un voyage en livre photo mis en page, page après page.',
|
||||
'demo.feature.collab.title': 'Planifier ensemble en direct',
|
||||
'demo.feature.collab.body': 'Chaque participant voit chaque modification aussitôt, avec chat, notes et sondages.',
|
||||
'demo.feature.journey.title': 'Carnet de voyage Journey',
|
||||
'demo.feature.journey.body': 'Récits, photos, humeurs, points forts et points faibles de chaque jour sur la route.',
|
||||
'demo.feature.atlas.title': 'Carte du monde Atlas',
|
||||
'demo.feature.atlas.body': 'Les pays et régions que vous avez visités, avec vos statistiques de voyage.',
|
||||
'demo.feature.vacay.title': 'Planificateur de congés Vacay',
|
||||
'demo.feature.vacay.body': 'Planifiez vos congés autour des jours fériés et voyez quand tout le monde est libre.',
|
||||
'demo.feature.collections.title': 'Listes de lieux',
|
||||
'demo.feature.collections.body':
|
||||
'Enregistrez des lieux d’un voyage à l’autre dans vos propres listes et copiez les dans n’importe quel plan.',
|
||||
'demo.feature.offline.title': 'Fonctionne hors ligne',
|
||||
'demo.feature.offline.body':
|
||||
'Installez TREK comme une application et gardez vos plans, cartes et listes sans connexion.',
|
||||
'demo.welcome.limitsTitle': 'Absent de la démo',
|
||||
'demo.limit.uploads': 'Envoyer des photos, documents et couvertures',
|
||||
'demo.limit.account': 'Modifier le compte, son mot de passe ou la 2FA',
|
||||
'demo.limit.apiKeys': 'Créer des clés API',
|
||||
'demo.limit.mcp': 'Connecter des clients MCP',
|
||||
'demo.limit.ai': 'Connecter votre propre modèle d’IA',
|
||||
'demo.welcome.fullTitle': 'La version complète ajoute',
|
||||
'demo.full.sso': 'Connexion avec OIDC et SSO',
|
||||
'demo.full.backups': 'Sauvegardes automatiques',
|
||||
'demo.full.plugins': 'Des plugins pour de nouvelles fonctions',
|
||||
'demo.full.assistant': 'L’assistant IA avec votre propre modèle',
|
||||
'demo.full.admin': 'Un espace d’administration pour les utilisateurs, modules et permissions',
|
||||
'demo.welcome.repo': 'Code source sur GitHub',
|
||||
'demo.welcome.start': 'Commencer la visite',
|
||||
'demo.welcome.credits': 'Crédits photo',
|
||||
// The photo credits, the welcome dialog's second view (components/Demo/DemoCredits).
|
||||
'demo.credits.title': 'Crédits photo',
|
||||
'demo.credits.intro':
|
||||
'Les photos de cette démo proviennent de Wikimedia Commons, chacune sous la licence indiquée à côté. Cette licence s’applique uniquement à la photo, pas à TREK ni au reste de la démo.',
|
||||
'demo.credits.by': 'par {author}',
|
||||
'demo.credits.publicDomain': 'Domaine public',
|
||||
'demo.credits.back': 'Retour',
|
||||
// The lock over a section the shared demo account may not change (components/Demo/DemoLock).
|
||||
'demo.lock.badge': 'Mode démo',
|
||||
'demo.lock.apiKeys': 'Impossible de créer des clés API dans la démo.',
|
||||
'demo.lock.mcp': 'Impossible de connecter des clients MCP dans la démo.',
|
||||
'demo.lock.ai': 'Impossible d’ajouter votre propre connexion IA dans la démo.',
|
||||
'demo.lock.aiTitle': 'Connecter votre propre modèle d’IA',
|
||||
'demo.lock.account': 'Le compte de démo partagé ne peut pas être modifié.',
|
||||
'demo.lock.language': 'La démo fonctionne toujours en anglais.',
|
||||
};
|
||||
|
||||
export default demo;
|
||||
@@ -27,6 +27,7 @@ import dashboard from './dashboard';
|
||||
import dawarich from './dawarich';
|
||||
import day from './day';
|
||||
import dayplan from './dayplan';
|
||||
import demo from './demo';
|
||||
import docsync from './docsync';
|
||||
import ferratas from './ferratas';
|
||||
import files from './files';
|
||||
@@ -118,6 +119,7 @@ const locale = {
|
||||
...pdf,
|
||||
...planner,
|
||||
...day,
|
||||
...demo,
|
||||
...memories,
|
||||
...collab,
|
||||
...perm,
|
||||
|
||||
@@ -291,7 +291,6 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.disable': 'Désactiver 2FA',
|
||||
'settings.mfa.toastEnabled': 'Authentification à deux facteurs activée',
|
||||
'settings.mfa.toastDisabled': 'Authentification à deux facteurs désactivée',
|
||||
'settings.mfa.demoBlocked': 'Non disponible en mode démo',
|
||||
'settings.toast.mapSaved': 'Paramètres de carte enregistrés',
|
||||
'settings.toast.keysSaved': 'Clés API enregistrées',
|
||||
'settings.toast.displaySaved': "Paramètres d'affichage enregistrés",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const demo: TranslationStrings = {
|
||||
// The welcome dialog a demo instance opens with (components/Demo/DemoWelcome).
|
||||
'demo.welcome.eyebrow': 'Ζωντανή επίδειξη',
|
||||
'demo.welcome.title': 'Καλώς ήρθες στο TREK',
|
||||
'demo.welcome.tagline':
|
||||
'Ο ταξιδιωτικός σχεδιαστής για να μοιράζεσαι: σχεδιάστε μαζί στον χάρτη σε πραγματικό χρόνο, από την πρώτη ιδέα ως το φωτοβιβλίο μετά το ταξίδι.',
|
||||
'demo.welcome.resetTitle': 'Όλα επαναφέρονται κάθε ώρα',
|
||||
'demo.welcome.resetBody':
|
||||
'Εξερεύνησε, επεξεργάσου και δημιούργησε όσο θέλεις. Κάθε ακέραιη ώρα η επίδειξη επιστρέφει στην αρχική της κατάσταση για τον επόμενο επισκέπτη.',
|
||||
'demo.welcome.resetIn': 'Επόμενη επαναφορά σε',
|
||||
'demo.welcome.tryTitle': 'Δοκίμασέ το τώρα',
|
||||
'demo.feature.planner.title': 'Σχεδιαστής ταξιδιών',
|
||||
'demo.feature.planner.body':
|
||||
'Μέρες, μέρη και διαδρομές στον χάρτη, με πτήσεις, ξενοδοχεία, εστιατόρια, κρατήσεις και κοινά έξοδα.',
|
||||
'demo.feature.tours.title': 'Διαδρομές και πεζοπορίες',
|
||||
'demo.feature.tours.body':
|
||||
'Σχεδίασε μια πεζοπορία στο δίκτυο μονοπατιών και ακολούθησέ την πάνω σε 3D ανάγλυφο με το υψομετρικό της προφίλ.',
|
||||
'demo.feature.studio.title': 'Φωτοβιβλία με το TREK Studio',
|
||||
'demo.feature.studio.body': 'Κάνε ένα ταξίδι σχεδιασμένο φωτοβιβλίο, σελίδα προς σελίδα.',
|
||||
'demo.feature.collab.title': 'Σχεδιάστε μαζί ζωντανά',
|
||||
'demo.feature.collab.body': 'Όλοι στο ταξίδι βλέπουν κάθε αλλαγή αμέσως, με συνομιλία, σημειώσεις και ψηφοφορίες.',
|
||||
'demo.feature.journey.title': 'Ταξιδιωτικό ημερολόγιο Journey',
|
||||
'demo.feature.journey.body': 'Καταχωρίσεις, φωτογραφίες, διαθέσεις, υπέρ και κατά για κάθε μέρα στον δρόμο.',
|
||||
'demo.feature.atlas.title': 'Παγκόσμιος χάρτης Atlas',
|
||||
'demo.feature.atlas.body': 'Οι χώρες και οι περιοχές που έχεις επισκεφθεί, με τα ταξιδιωτικά σου στατιστικά.',
|
||||
'demo.feature.vacay.title': 'Σχεδιαστής αδειών Vacay',
|
||||
'demo.feature.vacay.body': 'Σχεδίασε τις άδειές σου γύρω από τις αργίες και δες πότε είναι όλοι ελεύθεροι.',
|
||||
'demo.feature.collections.title': 'Συλλογές',
|
||||
'demo.feature.collections.body':
|
||||
'Αποθήκευσε μέρη από διάφορα ταξίδια σε δικές σου λίστες και αντίγραψέ τα σε οποιοδήποτε πλάνο.',
|
||||
'demo.feature.offline.title': 'Λειτουργεί εκτός σύνδεσης',
|
||||
'demo.feature.offline.body': 'Εγκατάστησε το TREK ως εφαρμογή και κράτα πλάνα, χάρτες και λίστες χωρίς σύνδεση.',
|
||||
'demo.welcome.limitsTitle': 'Εκτός επίδειξης',
|
||||
'demo.limit.uploads': 'Μεταφόρτωση φωτογραφιών, εγγράφων και εξωφύλλων',
|
||||
'demo.limit.account': 'Αλλαγή λογαριασμού, κωδικού ή 2FA',
|
||||
'demo.limit.apiKeys': 'Δημιουργία κλειδιών API',
|
||||
'demo.limit.mcp': 'Σύνδεση πελατών MCP',
|
||||
'demo.limit.ai': 'Σύνδεση δικού σου μοντέλου ΤΝ',
|
||||
'demo.welcome.fullTitle': 'Η πλήρης έκδοση προσθέτει',
|
||||
'demo.full.sso': 'Σύνδεση με OIDC και SSO',
|
||||
'demo.full.backups': 'Αυτόματα αντίγραφα ασφαλείας',
|
||||
'demo.full.plugins': 'Πρόσθετα για νέες λειτουργίες',
|
||||
'demo.full.assistant': 'Τον βοηθό ΤΝ με το δικό σου μοντέλο',
|
||||
'demo.full.admin': 'Πίνακα διαχείρισης για χρήστες, πρόσθετα και δικαιώματα',
|
||||
'demo.welcome.repo': 'Πηγαίος κώδικας στο GitHub',
|
||||
'demo.welcome.start': 'Ξεκίνα την εξερεύνηση',
|
||||
'demo.welcome.credits': 'Πηγές φωτογραφιών',
|
||||
// The photo credits, the welcome dialog's second view (components/Demo/DemoCredits).
|
||||
'demo.credits.title': 'Πηγές φωτογραφιών',
|
||||
'demo.credits.intro':
|
||||
'Οι φωτογραφίες αυτής της επίδειξης προέρχονται από το Wikimedia Commons, η καθεμία με την άδεια που αναγράφεται δίπλα της. Η άδεια αυτή ισχύει μόνο για τη φωτογραφία, όχι για το TREK ή την υπόλοιπη επίδειξη.',
|
||||
'demo.credits.by': 'Φωτογραφία: {author}',
|
||||
'demo.credits.publicDomain': 'Κοινό κτήμα',
|
||||
'demo.credits.back': 'Πίσω',
|
||||
// The lock over a section the shared demo account may not change (components/Demo/DemoLock).
|
||||
'demo.lock.badge': 'Λειτουργία επίδειξης',
|
||||
'demo.lock.apiKeys': 'Στην επίδειξη δεν δημιουργούνται κλειδιά API.',
|
||||
'demo.lock.mcp': 'Στην επίδειξη δεν συνδέονται πελάτες MCP.',
|
||||
'demo.lock.ai': 'Στην επίδειξη δεν προστίθεται δική σου σύνδεση ΤΝ.',
|
||||
'demo.lock.aiTitle': 'Σύνδεσε δικό σου μοντέλο ΤΝ',
|
||||
'demo.lock.account': 'Ο κοινός λογαριασμός επίδειξης δεν αλλάζει.',
|
||||
'demo.lock.language': 'Η επίδειξη τρέχει πάντα στα αγγλικά.',
|
||||
};
|
||||
|
||||
export default demo;
|
||||
@@ -27,6 +27,7 @@ import dashboard from './dashboard';
|
||||
import dawarich from './dawarich';
|
||||
import day from './day';
|
||||
import dayplan from './dayplan';
|
||||
import demo from './demo';
|
||||
import docsync from './docsync';
|
||||
import ferratas from './ferratas';
|
||||
import files from './files';
|
||||
@@ -118,6 +119,7 @@ const locale = {
|
||||
...pdf,
|
||||
...planner,
|
||||
...day,
|
||||
...demo,
|
||||
...memories,
|
||||
...collab,
|
||||
...perm,
|
||||
|
||||
@@ -361,7 +361,6 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.disable': 'Απενεργοποίηση 2FA',
|
||||
'settings.mfa.toastEnabled': 'Ο έλεγχος ταυτότητας δύο παραγόντων ενεργοποιήθηκε',
|
||||
'settings.mfa.toastDisabled': 'Ο έλεγχος ταυτότητας δύο παραγόντων απενεργοποιήθηκε',
|
||||
'settings.mfa.demoBlocked': 'Δεν είναι διαθέσιμο σε λειτουργία demo',
|
||||
'settings.currency': 'Νόμισμα εμφάνισης',
|
||||
'settings.currencyHint':
|
||||
'Τα ποσά στα Κόστη εμφανίζονται σε αυτό το νόμισμα μόνο για λόγους προβολής, τα αρχικά ποσά δεν αλλάζουν.',
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const demo: TranslationStrings = {
|
||||
// The welcome dialog a demo instance opens with (components/Demo/DemoWelcome).
|
||||
'demo.welcome.eyebrow': 'Élő demó',
|
||||
'demo.welcome.title': 'Üdv a TREK-ben',
|
||||
'demo.welcome.tagline':
|
||||
'A megosztható útitervező: tervezzetek együtt a térképen valós időben, az első ötlettől az utazás utáni fotókönyvig.',
|
||||
'demo.welcome.resetTitle': 'Óránként minden visszaáll',
|
||||
'demo.welcome.resetBody':
|
||||
'Nézz körül, szerkessz és hozz létre bármit. Minden egész órakor a demó visszatér a kiinduló állapotába a következő látogató számára.',
|
||||
'demo.welcome.resetIn': 'Következő visszaállítás',
|
||||
'demo.welcome.tryTitle': 'Próbáld ki most',
|
||||
'demo.feature.planner.title': 'Útitervező',
|
||||
'demo.feature.planner.body':
|
||||
'Napok, helyek és útvonalak a térképen, járatokkal, szállodákkal, éttermekkel, foglalásokkal és közös költségekkel.',
|
||||
'demo.feature.tours.title': 'Túrák és kirándulások',
|
||||
'demo.feature.tours.body': 'Tervezz túrát az ösvényhálózaton, és kövesd 3D terepen a magassági profillal.',
|
||||
'demo.feature.studio.title': 'Fotókönyvek a TREK Studióban',
|
||||
'demo.feature.studio.body': 'Alakíts egy utazást oldalról oldalra megtervezett fotókönyvvé.',
|
||||
'demo.feature.collab.title': 'Közös tervezés élőben',
|
||||
'demo.feature.collab.body':
|
||||
'Az utazás minden tagja azonnal látja a változásokat, csevegéssel, jegyzetekkel és szavazásokkal.',
|
||||
'demo.feature.journey.title': 'Journey útinapló',
|
||||
'demo.feature.journey.body': 'Bejegyzések, fotók, hangulatok, előnyök és hátrányok az út minden napjáról.',
|
||||
'demo.feature.atlas.title': 'Atlas világtérkép',
|
||||
'demo.feature.atlas.body': 'Az általad bejárt országok és régiók, utazási statisztikákkal.',
|
||||
'demo.feature.vacay.title': 'Vacay szabadságtervező',
|
||||
'demo.feature.vacay.body': 'Tervezd a szabadnapjaidat az ünnepek köré, és lásd, mikor ér rá mindenki.',
|
||||
'demo.feature.collections.title': 'Gyűjtemények',
|
||||
'demo.feature.collections.body': 'Ments helyeket különböző utakról saját listákba, és másold őket bármelyik tervbe.',
|
||||
'demo.feature.offline.title': 'Offline is működik',
|
||||
'demo.feature.offline.body':
|
||||
'Telepítsd a TREK-et alkalmazásként, és tartsd meg a terveket, térképeket és listákat kapcsolat nélkül is.',
|
||||
'demo.welcome.limitsTitle': 'Nem része a demónak',
|
||||
'demo.limit.uploads': 'Fotók, dokumentumok és borítók feltöltése',
|
||||
'demo.limit.account': 'A fiók, a jelszó vagy a 2FA módosítása',
|
||||
'demo.limit.apiKeys': 'API-kulcsok létrehozása',
|
||||
'demo.limit.mcp': 'MCP-kliensek csatlakoztatása',
|
||||
'demo.limit.ai': 'Saját AI-modell csatlakoztatása',
|
||||
'demo.welcome.fullTitle': 'A teljes verzióban ezen felül',
|
||||
'demo.full.sso': 'Bejelentkezés OIDC és SSO segítségével',
|
||||
'demo.full.backups': 'Automatikus biztonsági mentések',
|
||||
'demo.full.plugins': 'Bővítmények új funkciókhoz',
|
||||
'demo.full.assistant': 'Az AI-asszisztens saját modellel',
|
||||
'demo.full.admin': 'Adminfelület felhasználókhoz, kiegészítőkhöz és jogosultságokhoz',
|
||||
'demo.welcome.repo': 'Forráskód a GitHubon',
|
||||
'demo.welcome.start': 'Felfedezés indítása',
|
||||
'demo.welcome.credits': 'Fotók szerzői',
|
||||
// The photo credits, the welcome dialog's second view (components/Demo/DemoCredits).
|
||||
'demo.credits.title': 'Fotók szerzői',
|
||||
'demo.credits.intro':
|
||||
'A demó fotói a Wikimedia Commonsról származnak, mindegyik a mellette feltüntetett licenccel. Ez a licenc csak a fotóra vonatkozik, a TREK-re és a demó többi részére nem.',
|
||||
'demo.credits.by': 'Fotó: {author}',
|
||||
'demo.credits.publicDomain': 'Közkincs',
|
||||
'demo.credits.back': 'Vissza',
|
||||
// The lock over a section the shared demo account may not change (components/Demo/DemoLock).
|
||||
'demo.lock.badge': 'Demó mód',
|
||||
'demo.lock.apiKeys': 'A demóban nem hozhatók létre API-kulcsok.',
|
||||
'demo.lock.mcp': 'A demóban nem csatlakoztathatók MCP-kliensek.',
|
||||
'demo.lock.ai': 'A demóban nem adható hozzá saját AI-kapcsolat.',
|
||||
'demo.lock.aiTitle': 'Saját AI-modell csatlakoztatása',
|
||||
'demo.lock.account': 'A közös demófiók nem módosítható.',
|
||||
'demo.lock.language': 'A demó mindig angolul fut.',
|
||||
};
|
||||
|
||||
export default demo;
|
||||
@@ -27,6 +27,7 @@ import dashboard from './dashboard';
|
||||
import dawarich from './dawarich';
|
||||
import day from './day';
|
||||
import dayplan from './dayplan';
|
||||
import demo from './demo';
|
||||
import docsync from './docsync';
|
||||
import ferratas from './ferratas';
|
||||
import files from './files';
|
||||
@@ -118,6 +119,7 @@ const locale = {
|
||||
...pdf,
|
||||
...planner,
|
||||
...day,
|
||||
...demo,
|
||||
...collab,
|
||||
...memories,
|
||||
...perm,
|
||||
|
||||
@@ -295,7 +295,6 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.disable': '2FA kikapcsolása',
|
||||
'settings.mfa.toastEnabled': 'Kétfaktoros hitelesítés engedélyezve',
|
||||
'settings.mfa.toastDisabled': 'Kétfaktoros hitelesítés kikapcsolva',
|
||||
'settings.mfa.demoBlocked': 'Demo módban nem érhető el',
|
||||
'settings.mustChangePassword':
|
||||
'A folytatás előtt meg kell változtatnod a jelszavad. Kérjük, adj meg egy új jelszót alább.',
|
||||
'settings.bookingLabels': 'Útvonal-címkék a foglalásokhoz',
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const demo: TranslationStrings = {
|
||||
// The welcome dialog a demo instance opens with (components/Demo/DemoWelcome).
|
||||
'demo.welcome.eyebrow': 'Demo langsung',
|
||||
'demo.welcome.title': 'Selamat datang di TREK',
|
||||
'demo.welcome.tagline':
|
||||
'Perencana perjalanan untuk dibagikan: rencanakan bersama di peta secara langsung, dari ide pertama sampai buku foto setelah perjalanan.',
|
||||
'demo.welcome.resetTitle': 'Semuanya diatur ulang setiap jam',
|
||||
'demo.welcome.resetBody':
|
||||
'Jelajahi, ubah, dan buat sebanyak yang Anda mau. Setiap jam tepat, demo kembali ke keadaan awal untuk pengunjung berikutnya.',
|
||||
'demo.welcome.resetIn': 'Atur ulang berikutnya dalam',
|
||||
'demo.welcome.tryTitle': 'Coba sekarang juga',
|
||||
'demo.feature.planner.title': 'Perencana perjalanan',
|
||||
'demo.feature.planner.body':
|
||||
'Hari, tempat, dan rute di peta, dengan penerbangan, hotel, restoran, pemesanan, dan biaya bersama.',
|
||||
'demo.feature.tours.title': 'Tur dan pendakian',
|
||||
'demo.feature.tours.body':
|
||||
'Rencanakan pendakian di jaringan jalur dan ikuti di atas medan 3D dengan profil ketinggiannya.',
|
||||
'demo.feature.studio.title': 'Buku foto dengan TREK Studio',
|
||||
'demo.feature.studio.body': 'Ubah perjalanan menjadi buku foto yang tertata, halaman demi halaman.',
|
||||
'demo.feature.collab.title': 'Merencanakan bersama secara langsung',
|
||||
'demo.feature.collab.body':
|
||||
'Semua orang dalam perjalanan melihat setiap perubahan seketika, dengan obrolan, catatan, dan jajak pendapat.',
|
||||
'demo.feature.journey.title': 'Jurnal perjalanan Journey',
|
||||
'demo.feature.journey.body': 'Catatan, foto, suasana hati, kelebihan dan kekurangan setiap hari di perjalanan.',
|
||||
'demo.feature.atlas.title': 'Peta dunia Atlas',
|
||||
'demo.feature.atlas.body': 'Negara dan wilayah yang pernah Anda kunjungi, dengan statistik perjalanan Anda.',
|
||||
'demo.feature.vacay.title': 'Perencana cuti Vacay',
|
||||
'demo.feature.vacay.body': 'Rencanakan hari libur di sekitar hari raya dan lihat kapan semua orang senggang.',
|
||||
'demo.feature.collections.title': 'Koleksi',
|
||||
'demo.feature.collections.body':
|
||||
'Simpan tempat dari berbagai perjalanan di daftar Anda sendiri dan salin ke rencana mana pun.',
|
||||
'demo.feature.offline.title': 'Bisa luring',
|
||||
'demo.feature.offline.body': 'Pasang TREK sebagai aplikasi dan simpan rencana, peta, dan daftar tanpa koneksi.',
|
||||
'demo.welcome.limitsTitle': 'Tidak termasuk demo',
|
||||
'demo.limit.uploads': 'Mengunggah foto, dokumen, dan sampul',
|
||||
'demo.limit.account': 'Mengubah akun, kata sandinya, atau 2FA',
|
||||
'demo.limit.apiKeys': 'Membuat kunci API',
|
||||
'demo.limit.mcp': 'Menghubungkan klien MCP',
|
||||
'demo.limit.ai': 'Menghubungkan model AI Anda sendiri',
|
||||
'demo.welcome.fullTitle': 'Versi lengkap menambahkan',
|
||||
'demo.full.sso': 'Masuk dengan OIDC dan SSO',
|
||||
'demo.full.backups': 'Pencadangan otomatis',
|
||||
'demo.full.plugins': 'Plugin untuk fitur baru',
|
||||
'demo.full.assistant': 'Asisten AI dengan model Anda sendiri',
|
||||
'demo.full.admin': 'Panel admin untuk pengguna, addon, dan izin',
|
||||
'demo.welcome.repo': 'Kode sumber di GitHub',
|
||||
'demo.welcome.start': 'Mulai menjelajah',
|
||||
'demo.welcome.credits': 'Kredit foto',
|
||||
// The photo credits, the welcome dialog's second view (components/Demo/DemoCredits).
|
||||
'demo.credits.title': 'Kredit foto',
|
||||
'demo.credits.intro':
|
||||
'Foto dalam demo ini berasal dari Wikimedia Commons, masing-masing dengan lisensi yang tertera di sampingnya. Lisensi itu hanya berlaku untuk fotonya, bukan untuk TREK atau bagian lain demo ini.',
|
||||
'demo.credits.by': 'oleh {author}',
|
||||
'demo.credits.publicDomain': 'Domain publik',
|
||||
'demo.credits.back': 'Kembali',
|
||||
// The lock over a section the shared demo account may not change (components/Demo/DemoLock).
|
||||
'demo.lock.badge': 'Mode demo',
|
||||
'demo.lock.apiKeys': 'Kunci API tidak dapat dibuat di demo.',
|
||||
'demo.lock.mcp': 'Klien MCP tidak dapat dihubungkan di demo.',
|
||||
'demo.lock.ai': 'Koneksi AI sendiri tidak dapat ditambahkan di demo.',
|
||||
'demo.lock.aiTitle': 'Hubungkan model AI Anda sendiri',
|
||||
'demo.lock.account': 'Akun demo bersama tidak dapat diubah.',
|
||||
'demo.lock.language': 'Demo selalu berjalan dalam bahasa Inggris.',
|
||||
};
|
||||
|
||||
export default demo;
|
||||
@@ -27,6 +27,7 @@ import dashboard from './dashboard';
|
||||
import dawarich from './dawarich';
|
||||
import day from './day';
|
||||
import dayplan from './dayplan';
|
||||
import demo from './demo';
|
||||
import docsync from './docsync';
|
||||
import ferratas from './ferratas';
|
||||
import files from './files';
|
||||
@@ -118,6 +119,7 @@ const locale = {
|
||||
...pdf,
|
||||
...planner,
|
||||
...day,
|
||||
...demo,
|
||||
...memories,
|
||||
...collab,
|
||||
...perm,
|
||||
|
||||
@@ -347,7 +347,6 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.disable': 'Nonaktifkan 2FA',
|
||||
'settings.mfa.toastEnabled': 'Autentikasi dua faktor diaktifkan',
|
||||
'settings.mfa.toastDisabled': 'Autentikasi dua faktor dinonaktifkan',
|
||||
'settings.mfa.demoBlocked': 'Tidak tersedia dalam mode demo',
|
||||
'settings.bookingLabels': 'Label rute pemesanan',
|
||||
'settings.bookingLabelsHint': 'Menampilkan nama stasiun / bandara di peta. Jika mati, hanya ikon ditampilkan.',
|
||||
'settings.currency': 'Mata uang tampilan',
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const demo: TranslationStrings = {
|
||||
// The welcome dialog a demo instance opens with (components/Demo/DemoWelcome).
|
||||
'demo.welcome.eyebrow': 'Demo dal vivo',
|
||||
'demo.welcome.title': 'Benvenuto in TREK',
|
||||
'demo.welcome.tagline':
|
||||
'Il pianificatore di viaggi da condividere: pianificate insieme sulla mappa in tempo reale, dalla prima idea al fotolibro dopo il viaggio.',
|
||||
'demo.welcome.resetTitle': 'Tutto viene ripristinato ogni ora',
|
||||
'demo.welcome.resetBody':
|
||||
'Esplora, modifica e crea quanto vuoi. Allo scoccare di ogni ora la demo torna allo stato iniziale per il visitatore successivo.',
|
||||
'demo.welcome.resetIn': 'Prossimo ripristino tra',
|
||||
'demo.welcome.tryTitle': 'Provalo subito',
|
||||
'demo.feature.planner.title': 'Pianificatore di viaggi',
|
||||
'demo.feature.planner.body':
|
||||
'Giorni, luoghi e percorsi sulla mappa, con voli, hotel, ristoranti, prenotazioni e spese condivise.',
|
||||
'demo.feature.tours.title': 'Tour ed escursioni',
|
||||
'demo.feature.tours.body':
|
||||
'Pianifica un’escursione sulla rete dei sentieri e seguila sul terreno 3D con il suo profilo altimetrico.',
|
||||
'demo.feature.studio.title': 'Fotolibri con TREK Studio',
|
||||
'demo.feature.studio.body': 'Trasforma un viaggio in un fotolibro impaginato, pagina dopo pagina.',
|
||||
'demo.feature.collab.title': 'Pianificare insieme dal vivo',
|
||||
'demo.feature.collab.body':
|
||||
'Tutti nel viaggio vedono ogni modifica nel momento in cui avviene, con chat, note e sondaggi.',
|
||||
'demo.feature.journey.title': 'Diario di viaggio Journey',
|
||||
'demo.feature.journey.body': 'Voci, foto, stati d’animo, pro e contro di ogni giorno in viaggio.',
|
||||
'demo.feature.atlas.title': 'Mappa del mondo Atlas',
|
||||
'demo.feature.atlas.body': 'I paesi e le regioni che hai visitato, con le tue statistiche di viaggio.',
|
||||
'demo.feature.vacay.title': 'Pianificatore di ferie Vacay',
|
||||
'demo.feature.vacay.body': 'Pianifica i giorni liberi attorno alle festività e scopri quando sono tutti liberi.',
|
||||
'demo.feature.collections.title': 'Collezioni',
|
||||
'demo.feature.collections.body': 'Salva luoghi di viaggi diversi in liste tue e copiali in qualsiasi piano.',
|
||||
'demo.feature.offline.title': 'Funziona offline',
|
||||
'demo.feature.offline.body': 'Installa TREK come app e conserva piani, mappe e liste anche senza connessione.',
|
||||
'demo.welcome.limitsTitle': 'Escluso dalla demo',
|
||||
'demo.limit.uploads': 'Caricare foto, documenti e copertine',
|
||||
'demo.limit.account': 'Modificare l’account, la password o la 2FA',
|
||||
'demo.limit.apiKeys': 'Creare chiavi API',
|
||||
'demo.limit.mcp': 'Collegare client MCP',
|
||||
'demo.limit.ai': 'Collegare un tuo modello di IA',
|
||||
'demo.welcome.fullTitle': 'La versione completa aggiunge',
|
||||
'demo.full.sso': 'Accesso con OIDC e SSO',
|
||||
'demo.full.backups': 'Backup automatici',
|
||||
'demo.full.plugins': 'Plugin per nuove funzioni',
|
||||
'demo.full.assistant': 'L’assistente IA con il tuo modello',
|
||||
'demo.full.admin': 'Un pannello di amministrazione per utenti, componenti aggiuntivi e permessi',
|
||||
'demo.welcome.repo': 'Codice sorgente su GitHub',
|
||||
'demo.welcome.start': 'Inizia a esplorare',
|
||||
'demo.welcome.credits': 'Crediti fotografici',
|
||||
// The photo credits, the welcome dialog's second view (components/Demo/DemoCredits).
|
||||
'demo.credits.title': 'Crediti fotografici',
|
||||
'demo.credits.intro':
|
||||
'Le foto di questa demo provengono da Wikimedia Commons, ognuna con la licenza indicata accanto. Quella licenza vale solo per la foto, non per TREK né per il resto della demo.',
|
||||
'demo.credits.by': 'Foto di {author}',
|
||||
'demo.credits.publicDomain': 'Pubblico dominio',
|
||||
'demo.credits.back': 'Indietro',
|
||||
// The lock over a section the shared demo account may not change (components/Demo/DemoLock).
|
||||
'demo.lock.badge': 'Modalità demo',
|
||||
'demo.lock.apiKeys': 'Nella demo non si possono creare chiavi API.',
|
||||
'demo.lock.mcp': 'Nella demo non si possono collegare client MCP.',
|
||||
'demo.lock.ai': 'Nella demo non si può aggiungere una connessione IA propria.',
|
||||
'demo.lock.aiTitle': 'Collega un tuo modello di IA',
|
||||
'demo.lock.account': 'L’account demo condiviso non può essere modificato.',
|
||||
'demo.lock.language': 'La demo funziona sempre in inglese.',
|
||||
};
|
||||
|
||||
export default demo;
|
||||
@@ -27,6 +27,7 @@ import dashboard from './dashboard';
|
||||
import dawarich from './dawarich';
|
||||
import day from './day';
|
||||
import dayplan from './dayplan';
|
||||
import demo from './demo';
|
||||
import docsync from './docsync';
|
||||
import ferratas from './ferratas';
|
||||
import files from './files';
|
||||
@@ -118,6 +119,7 @@ const locale = {
|
||||
...pdf,
|
||||
...planner,
|
||||
...day,
|
||||
...demo,
|
||||
...memories,
|
||||
...collab,
|
||||
...perm,
|
||||
|
||||
@@ -294,7 +294,6 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.disable': 'Disabilita 2FA',
|
||||
'settings.mfa.toastEnabled': 'Autenticazione a due fattori abilitata',
|
||||
'settings.mfa.toastDisabled': 'Autenticazione a due fattori disabilitata',
|
||||
'settings.mfa.demoBlocked': 'Non disponibile in modalità demo',
|
||||
'settings.mustChangePassword': 'Devi cambiare la password prima di continuare. Imposta una nuova password qui sotto.',
|
||||
'settings.bookingLabels': 'Etichette percorsi prenotati',
|
||||
'settings.bookingLabelsHint':
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const demo: TranslationStrings = {
|
||||
// The welcome dialog a demo instance opens with (components/Demo/DemoWelcome).
|
||||
'demo.welcome.eyebrow': 'ライブデモ',
|
||||
'demo.welcome.title': 'TREK へようこそ',
|
||||
'demo.welcome.tagline':
|
||||
'共有できる旅行プランナー。地図の上でリアルタイムに一緒に計画し、最初のアイデアから旅のあとのフォトブックまで。',
|
||||
'demo.welcome.resetTitle': '毎時すべてがリセットされます',
|
||||
'demo.welcome.resetBody':
|
||||
'自由に見て、編集して、作成してください。毎正時にデモは次の訪問者のために最初の状態へ戻ります。',
|
||||
'demo.welcome.resetIn': '次のリセットまで',
|
||||
'demo.welcome.tryTitle': 'さっそく試す',
|
||||
'demo.feature.planner.title': '旅行プランナー',
|
||||
'demo.feature.planner.body': '地図上の日程、スポット、ルートに、フライト、ホテル、レストラン、予約、共有費用まで。',
|
||||
'demo.feature.tours.title': 'ツアーとハイキング',
|
||||
'demo.feature.tours.body': '登山道のネットワークでハイキングを計画し、標高プロファイル付きの 3D 地形でたどれます。',
|
||||
'demo.feature.studio.title': 'TREK Studio のフォトブック',
|
||||
'demo.feature.studio.body': '旅をページごとにデザインされたフォトブックに仕上げます。',
|
||||
'demo.feature.collab.title': 'ライブで一緒に計画',
|
||||
'demo.feature.collab.body': '旅のメンバー全員が変更をすぐに確認でき、チャット、メモ、投票も使えます。',
|
||||
'demo.feature.journey.title': '旅行記 Journey',
|
||||
'demo.feature.journey.body': '旅の毎日の記録、写真、気分、良かった点と気になった点。',
|
||||
'demo.feature.atlas.title': '世界地図 Atlas',
|
||||
'demo.feature.atlas.body': '訪れた国と地域を、旅の統計とともに。',
|
||||
'demo.feature.vacay.title': '休暇プランナー Vacay',
|
||||
'demo.feature.vacay.body': '祝日に合わせて休みを計画し、みんなが空いている日がわかります。',
|
||||
'demo.feature.collections.title': 'コレクション',
|
||||
'demo.feature.collections.body': '旅をまたいでスポットを自分のリストに保存し、どのプランにもコピーできます。',
|
||||
'demo.feature.offline.title': 'オフラインでも動作',
|
||||
'demo.feature.offline.body': 'TREK をアプリとしてインストールすれば、接続がなくてもプラン、地図、リストを使えます。',
|
||||
'demo.welcome.limitsTitle': 'デモでは使えないもの',
|
||||
'demo.limit.uploads': '写真、書類、カバー画像のアップロード',
|
||||
'demo.limit.account': 'アカウント、パスワード、2FA の変更',
|
||||
'demo.limit.apiKeys': 'API キーの作成',
|
||||
'demo.limit.mcp': 'MCP クライアントの接続',
|
||||
'demo.limit.ai': '自分の AI モデルの接続',
|
||||
'demo.welcome.fullTitle': 'フル版で追加されるもの',
|
||||
'demo.full.sso': 'OIDC と SSO によるサインイン',
|
||||
'demo.full.backups': '自動バックアップ',
|
||||
'demo.full.plugins': '新機能を加えるプラグイン',
|
||||
'demo.full.assistant': '自分のモデルで動く AI アシスタント',
|
||||
'demo.full.admin': 'ユーザー、アドオン、権限を管理する管理画面',
|
||||
'demo.welcome.repo': 'GitHub のソースコード',
|
||||
'demo.welcome.start': '使ってみる',
|
||||
'demo.welcome.credits': '写真のクレジット',
|
||||
// The photo credits, the welcome dialog's second view (components/Demo/DemoCredits).
|
||||
'demo.credits.title': '写真のクレジット',
|
||||
'demo.credits.intro':
|
||||
'このデモの写真は Wikimedia Commons のもので、それぞれ横に記載したライセンスで公開されています。そのライセンスは写真にのみ適用され、TREK やデモのほかの部分には適用されません。',
|
||||
'demo.credits.by': '撮影:{author}',
|
||||
'demo.credits.publicDomain': 'パブリックドメイン',
|
||||
'demo.credits.back': '戻る',
|
||||
// The lock over a section the shared demo account may not change (components/Demo/DemoLock).
|
||||
'demo.lock.badge': 'デモモード',
|
||||
'demo.lock.apiKeys': 'デモでは API キーを作成できません。',
|
||||
'demo.lock.mcp': 'デモでは MCP クライアントを接続できません。',
|
||||
'demo.lock.ai': 'デモでは自分の AI 接続を追加できません。',
|
||||
'demo.lock.aiTitle': '自分の AI モデルを接続',
|
||||
'demo.lock.account': '共有のデモアカウントは変更できません。',
|
||||
'demo.lock.language': 'デモは常に英語で動作します。',
|
||||
};
|
||||
|
||||
export default demo;
|
||||
@@ -27,6 +27,7 @@ import dashboard from './dashboard';
|
||||
import dawarich from './dawarich';
|
||||
import day from './day';
|
||||
import dayplan from './dayplan';
|
||||
import demo from './demo';
|
||||
import docsync from './docsync';
|
||||
import ferratas from './ferratas';
|
||||
import files from './files';
|
||||
@@ -118,6 +119,7 @@ const locale = {
|
||||
...pdf,
|
||||
...planner,
|
||||
...day,
|
||||
...demo,
|
||||
...memories,
|
||||
...collab,
|
||||
...perm,
|
||||
|
||||
@@ -321,7 +321,6 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.disable': '2FAを無効化',
|
||||
'settings.mfa.toastEnabled': '2FAを有効にしました',
|
||||
'settings.mfa.toastDisabled': '2FAを無効にしました',
|
||||
'settings.mfa.demoBlocked': 'デモモードでは利用できません',
|
||||
'settings.oauth.modal.machineClient': 'マシンクライアント(ブラウザログインなし)',
|
||||
'settings.oauth.modal.machineClientHint':
|
||||
'client_credentials グラントを使用します — リダイレクト URI は不要です。トークンは client_id + client_secret を介して直接発行され、選択したスコープ内であなたとして動作します。',
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const demo: TranslationStrings = {
|
||||
// The welcome dialog a demo instance opens with (components/Demo/DemoWelcome).
|
||||
'demo.welcome.eyebrow': '라이브 데모',
|
||||
'demo.welcome.title': 'TREK에 오신 것을 환영합니다',
|
||||
'demo.welcome.tagline':
|
||||
'함께 쓰는 여행 플래너: 지도 위에서 실시간으로 함께 계획하고, 첫 아이디어부터 여행 후 포토북까지.',
|
||||
'demo.welcome.resetTitle': '매시간 모든 것이 초기화됩니다',
|
||||
'demo.welcome.resetBody':
|
||||
'마음껏 둘러보고 수정하고 만들어 보세요. 매시 정각에 데모는 다음 방문자를 위해 처음 상태로 돌아갑니다.',
|
||||
'demo.welcome.resetIn': '다음 초기화까지',
|
||||
'demo.welcome.tryTitle': '지금 바로 체험',
|
||||
'demo.feature.planner.title': '여행 플래너',
|
||||
'demo.feature.planner.body': '지도 위의 일정, 장소, 경로와 함께 항공편, 호텔, 식당, 예약, 공동 비용까지.',
|
||||
'demo.feature.tours.title': '투어와 하이킹',
|
||||
'demo.feature.tours.body': '등산로 네트워크에서 하이킹을 계획하고 고도 프로필과 함께 3D 지형 위에서 따라가 보세요.',
|
||||
'demo.feature.studio.title': 'TREK Studio 포토북',
|
||||
'demo.feature.studio.body': '여행을 한 페이지씩 디자인된 포토북으로 만들어 보세요.',
|
||||
'demo.feature.collab.title': '실시간으로 함께 계획',
|
||||
'demo.feature.collab.body': '여행 멤버 모두가 모든 변경을 바로 보고, 채팅, 메모, 투표도 함께합니다.',
|
||||
'demo.feature.journey.title': '여행 일지 Journey',
|
||||
'demo.feature.journey.body': '길 위의 매일을 위한 기록, 사진, 기분, 장단점.',
|
||||
'demo.feature.atlas.title': '세계 지도 Atlas',
|
||||
'demo.feature.atlas.body': '방문한 국가와 지역을 여행 통계와 함께 보여 줍니다.',
|
||||
'demo.feature.vacay.title': '휴가 플래너 Vacay',
|
||||
'demo.feature.vacay.body': '공휴일에 맞춰 휴가를 계획하고 모두가 비는 날을 확인하세요.',
|
||||
'demo.feature.collections.title': '컬렉션',
|
||||
'demo.feature.collections.body': '여러 여행의 장소를 나만의 목록에 저장하고 어떤 계획에도 복사하세요.',
|
||||
'demo.feature.offline.title': '오프라인에서도 작동',
|
||||
'demo.feature.offline.body': 'TREK을 앱으로 설치하면 연결 없이도 계획, 지도, 목록을 쓸 수 있습니다.',
|
||||
'demo.welcome.limitsTitle': '데모에서 제외된 기능',
|
||||
'demo.limit.uploads': '사진, 문서, 표지 업로드',
|
||||
'demo.limit.account': '계정, 비밀번호 또는 2FA 변경',
|
||||
'demo.limit.apiKeys': 'API 키 만들기',
|
||||
'demo.limit.mcp': 'MCP 클라이언트 연결',
|
||||
'demo.limit.ai': '내 AI 모델 연결',
|
||||
'demo.welcome.fullTitle': '정식 버전에 추가되는 기능',
|
||||
'demo.full.sso': 'OIDC 및 SSO 로그인',
|
||||
'demo.full.backups': '자동 백업',
|
||||
'demo.full.plugins': '새 기능을 위한 플러그인',
|
||||
'demo.full.assistant': '내 모델로 동작하는 AI 어시스턴트',
|
||||
'demo.full.admin': '사용자, 애드온, 권한을 위한 관리자 패널',
|
||||
'demo.welcome.repo': 'GitHub의 소스 코드',
|
||||
'demo.welcome.start': '둘러보기 시작',
|
||||
'demo.welcome.credits': '사진 출처',
|
||||
// The photo credits, the welcome dialog's second view (components/Demo/DemoCredits).
|
||||
'demo.credits.title': '사진 출처',
|
||||
'demo.credits.intro':
|
||||
'이 데모의 사진은 Wikimedia Commons에서 가져왔으며, 각 사진에는 함께 표시된 라이선스가 적용됩니다. 이 라이선스는 사진에만 적용되며 TREK이나 데모의 나머지 부분에는 적용되지 않습니다.',
|
||||
'demo.credits.by': '촬영: {author}',
|
||||
'demo.credits.publicDomain': '퍼블릭 도메인',
|
||||
'demo.credits.back': '뒤로',
|
||||
// The lock over a section the shared demo account may not change (components/Demo/DemoLock).
|
||||
'demo.lock.badge': '데모 모드',
|
||||
'demo.lock.apiKeys': '데모에서는 API 키를 만들 수 없습니다.',
|
||||
'demo.lock.mcp': '데모에서는 MCP 클라이언트를 연결할 수 없습니다.',
|
||||
'demo.lock.ai': '데모에서는 내 AI 연결을 추가할 수 없습니다.',
|
||||
'demo.lock.aiTitle': '내 AI 모델 연결',
|
||||
'demo.lock.account': '공유 데모 계정은 변경할 수 없습니다.',
|
||||
'demo.lock.language': '데모는 항상 영어로 실행됩니다.',
|
||||
};
|
||||
|
||||
export default demo;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user