diff --git a/Dockerfile b/Dockerfile index e25d04f73..a469a1765 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,11 +31,21 @@ ARG VITE_WELCOME_DISABLED= # postMessage API. Usually set at RUN time instead (-e GEOLIBRE_EMBED_ORIGINS=…), # which the entrypoint writes into the runtime config without a rebuild. ARG VITE_GEOLIBRE_EMBED_ORIGINS= +# Self-hosted project sharing server (https://…, or "off" to remove Share and the +# Project Gallery). Unset uses the public hosted service. Like the embed origins, +# normally set at RUN time instead (-e GEOLIBRE_SHARE_URL=…) so a prebuilt image +# can be repointed without a rebuild. +ARG VITE_GEOLIBRE_SHARE_URL= +# Self-hosted collaboration relay (wss://…). Unset leaves collaboration dark. +# Also settable at RUN time (-e GEOLIBRE_COLLAB_URL=…). +ARG VITE_GEOLIBRE_COLLAB_URL= ENV GEOLIBRE_APP_BASE=${GEOLIBRE_APP_BASE} ENV VITE_GEE_OAUTH_CLIENT_ID=${VITE_GEE_OAUTH_CLIENT_ID} ENV VITE_MAPILLARY_ACCESS_TOKEN=${VITE_MAPILLARY_ACCESS_TOKEN} ENV VITE_WELCOME_DISABLED=${VITE_WELCOME_DISABLED} ENV VITE_GEOLIBRE_EMBED_ORIGINS=${VITE_GEOLIBRE_EMBED_ORIGINS} +ENV VITE_GEOLIBRE_SHARE_URL=${VITE_GEOLIBRE_SHARE_URL} +ENV VITE_GEOLIBRE_COLLAB_URL=${VITE_GEOLIBRE_COLLAB_URL} RUN npm run build diff --git a/apps/geolibre-desktop/src/components/layout/ProjectGalleryDialog.tsx b/apps/geolibre-desktop/src/components/layout/ProjectGalleryDialog.tsx index be2cfb648..8508d388e 100644 --- a/apps/geolibre-desktop/src/components/layout/ProjectGalleryDialog.tsx +++ b/apps/geolibre-desktop/src/components/layout/ProjectGalleryDialog.tsx @@ -38,6 +38,7 @@ import { projectOpenToken, type SharedProject, } from "../../lib/share-gallery"; +import { shareHostLabel } from "../../lib/share-geolibre"; import type { TFunction } from "i18next"; type GalleryScope = "featured" | "all" | "mine"; @@ -77,13 +78,15 @@ function galleryErrorMessage(error: unknown, t: TFunction): string { case "timeout": return t("gallery.errorTimeout"); case "network": - return t("gallery.errorNetwork"); + return t("gallery.errorNetwork", { shareHost: shareHostLabel() }); case "invalid-response": return t("gallery.errorInvalidResponse"); case "unauthorized": - return t("gallery.errorUnauthorized"); + return t("gallery.errorUnauthorized", { shareHost: shareHostLabel() }); case "username-required": - return t("gallery.errorUsernameRequired"); + return t("gallery.errorUsernameRequired", { shareHost: shareHostLabel() }); + case "not-configured": + return t("gallery.errorNotConfigured"); case "http": return t("gallery.errorHttp", { status: error.status ?? 0 }); } @@ -92,7 +95,8 @@ function galleryErrorMessage(error: unknown, t: TFunction): string { } /** - * Browse public projects shared on share.geolibre.app and open one in GeoLibre. + * Browse public projects shared on the configured share host and open one in + * GeoLibre. * * The listing endpoint only paginates (no server-side search), so this loads * pages on demand via "Load more" and filters the already-loaded set in the @@ -322,7 +326,9 @@ export function ProjectGalleryDialog({ > {t("gallery.title")} - {t("gallery.description")} + + {t("gallery.description", { shareHost: shareHostLabel() })} +
@@ -360,7 +366,9 @@ export function ProjectGalleryDialog({
{!hasToken ? ( -

{t("gallery.signedOutHint")}

+

+ {t("gallery.signedOutHint", { shareHost: shareHostLabel() })} +

) : null} {openError ? ( diff --git a/apps/geolibre-desktop/src/components/layout/SettingsDialog.tsx b/apps/geolibre-desktop/src/components/layout/SettingsDialog.tsx index 4d253a154..bb814600d 100644 --- a/apps/geolibre-desktop/src/components/layout/SettingsDialog.tsx +++ b/apps/geolibre-desktop/src/components/layout/SettingsDialog.tsx @@ -91,6 +91,7 @@ import type { ThemeMode } from "../../hooks/useThemeMode"; import { isTauri } from "../../lib/is-tauri"; import { THEME_SCHEMES, normalizeHexColor, type ThemeScheme } from "../../lib/theme-schemes"; import { IS_MAS_BUILD } from "../../lib/build-flags"; +import { resolveShareHost, shareHostLabel } from "../../lib/share-geolibre"; import { IS_STORE_BUILD, type UpdateNotificationLevel } from "../../lib/updates"; import { DATA_SOURCE_CATALOG, @@ -378,6 +379,25 @@ export function SettingsDialog({ onToggleThemeMode, }: SettingsDialogProps) { const { t } = useTranslation(); + // The share host's settings page, where the API token below is created. + // Derived from the resolved host so a self-hosted deployment links to its own + // page; null when the deployment configured no share host, in which case the + // description renders without a link rather than pointing at a stranger's site. + const shareHostState = resolveShareHost(); + const shareBaseUrl = shareHostState.baseUrl; + const shareHost = shareHostLabel(); + const shareSettingsUrl = shareBaseUrl ? `${shareBaseUrl}/settings` : null; + // No usable host (sharing turned off, or a configured address that was + // rejected) means the token field is dead: it would authenticate against a + // server this deployment never talks to. Say so instead of rendering guidance + // that names the public hosted service — the whole point of the opt-out. The + // two unusable states get different copy: "not configured" would send an + // operator who typo'd the variable looking for one they never set. + const shareTokenUsable = shareBaseUrl != null; + const shareTokenUnavailableMessage = + shareHostState.status === "invalid" + ? t("settings.env.tokenHostInvalid") + : t("settings.env.tokenUnavailable"); const { language, options: languageOptions, setLanguage } = useLanguage(); const preferences = useAppStore((s) => s.preferences); const setPreferences = useAppStore((s) => s.setPreferences); @@ -2273,33 +2293,44 @@ export function SettingsDialog({

{t("settings.env.tokenTitle")}

-

- - ), - }} - /> -

- updateShareToken(event.target.value)} - /> -

- {t("settings.env.tokenStorageNote")} -

+ {shareTokenUsable ? ( + <> +

+ + ), + }} + /> +

+ updateShareToken(event.target.value)} + /> +

+ {t("settings.env.tokenStorageNote", { shareHost })} +

+ + ) : ( +

+ {shareTokenUnavailableMessage} +

+ )}

{t("settings.env.cesiumTokenTitle")}

diff --git a/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx b/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx index c692a88ed..19b728d1d 100644 --- a/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx +++ b/apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx @@ -18,6 +18,7 @@ import { isShareableTitle, MAX_PROJECT_TITLE_LENGTH, resolveShareBaseUrl, + shareHostLabel, ShareUploadError, uploadProjectToShare, type ShareUploadErrorCode, @@ -38,9 +39,18 @@ interface ShareProjectDialogProps { getProject: (title: string) => Promise<{ content: string; filename: string }>; } -// The website's account settings page, where the user both creates API tokens -// and sets the username required for sharing. -const ACCOUNT_SETTINGS_URL = `${resolveShareBaseUrl()}/settings`; +/** + * The share host's account settings page, where the user both creates API tokens + * and sets the username required for sharing. + * + * Derived from the resolved host rather than hardcoded, so a self-hosted + * deployment sends its users to its own settings page. Null when no share host is + * configured, in which case the dialog does not render the link. + */ +function accountSettingsUrl(): string | null { + const base = resolveShareBaseUrl(); + return base ? `${base}/settings` : null; +} export function ShareProjectDialog({ open, @@ -49,6 +59,11 @@ export function ShareProjectDialog({ getProject, }: ShareProjectDialogProps) { const { t } = useTranslation(); + // Resolved per render rather than at module load so a deployment env written + // after this module was imported is still honored. + const settingsUrl = accountSettingsUrl(); + // Named in the copy below, so a self-hosted deployment reads its own host. + const shareHost = shareHostLabel(); const shareToken = useDesktopSettingsStore((s) => s.desktopSettings.shareToken); const [title, setTitle] = useState(""); const [visibility, setVisibility] = useState("unlisted"); @@ -158,6 +173,26 @@ export function ShareProjectDialog({ }); }; + // Defensive: the Share entry points (menu item and command palette) are gated on + // the same state, so this should be unreachable. Guarding here anyway keeps a + // future caller from rendering setup guidance that names the public hosted + // service on a deployment that configured no share host. + if (!settingsUrl) { + return ( + + + + + + {t("share.title")} + + {t("gallery.errorNotConfigured")} + + + + ); + } + return ( @@ -166,20 +201,22 @@ export function ShareProjectDialog({ {t("share.title")} - {t("share.description")} + {t("share.description", { shareHost })} {!hasToken ? (
-

{t("share.setupIntro")}

+

{t("share.setupIntro", { shareHost })}

  1. {t("share.step1Title")}

    -

    {t("share.step1Description")}

    +

    + {t("share.step1Description", { shareHost })} +