Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 156 additions & 4 deletions apps/geolibre-desktop/src/components/layout/ShareProjectDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useAppStore } from "@geolibre/core";
import { isEmbeddableLocalVectorLayer } from "@geolibre/plugins";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import {
Button,
Dialog,
Expand All @@ -9,7 +11,16 @@ import {
Label,
Select,
} from "@geolibre/ui";
import { Check, Copy, ExternalLink, KeyRound, Loader2, Share2 } from "lucide-react";
import {
Check,
CircleCheck,
Copy,
ExternalLink,
KeyRound,
Loader2,
Share2,
TriangleAlert,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useDesktopSettingsStore } from "../../hooks/useDesktopSettings";
Expand All @@ -25,6 +36,11 @@ import {
type ShareUploadResult,
type ShareVisibility,
} from "../../lib/share-geolibre";
import {
checkShareReadiness,
type ShareReadinessItem,
type ShareReadinessReport,
} from "../../lib/share-readiness";
import { openSettingsSection } from "./SettingsDialog";

interface ShareProjectDialogProps {
Expand Down Expand Up @@ -54,6 +70,55 @@ function accountSettingsUrl(): string | null {
return base ? `${base}/settings` : null;
}

/**
* The plain-language reason shown for a verdict, and what the author can do
* about it. Keyed off the reason rather than the status so an unreachable host
* and a stripped credential read differently even though both are fatal for a
* recipient. An `unchecked` verdict short-circuits: whatever reason it carries,
* the honest thing to say is that the check did not settle it.
*/
function readinessCopyKeys(item: ShareReadinessItem) {
if (item.status === "unchecked") {
return { reason: "share.readinessReasonUnchecked", advice: null } as const;
}
switch (item.reason) {
case "credential-stripped":
return {
reason: "share.readinessReasonCredentialStripped",
advice: "share.readinessAdviceCredential",
} as const;
case "auth-required":
return {
reason: "share.readinessReasonAuthRequired",
advice: "share.readinessAdviceCredential",
} as const;
case "cors":
return { reason: "share.readinessReasonCors", advice: "share.readinessAdviceCors" } as const;
case "not-found":
return {
reason: "share.readinessReasonNotFound",
advice: "share.readinessAdviceNotFound",
} as const;
case "local-file":
return {
reason: "share.readinessReasonLocalFile",
advice: "share.readinessAdviceLocal",
} as const;
case "private-host":
return {
reason: "share.readinessReasonPrivateHost",
advice: "share.readinessAdviceLocal",
} as const;
case "no-source":
return {
reason: "share.readinessReasonNoSource",
advice: "share.readinessAdviceLocal",
} as const;
default:
return { reason: "share.readinessReasonUnchecked", advice: null } as const;
}
}

export function ShareProjectDialog({
open,
onOpenChange,
Expand All @@ -75,9 +140,14 @@ export function ShareProjectDialog({
const [result, setResult] = useState<ShareUploadResult | null>(null);
const [copied, setCopied] = useState(false);
const [redactedCount, setRedactedCount] = useState(0);
const [readiness, setReadiness] = useState<ShareReadinessReport | null>(null);
const [readinessState, setReadinessState] = useState<"idle" | "checking" | "failed">("idle");
const abortRef = useRef<AbortController | null>(null);
const copyTimeoutRef = useRef<number | null>(null);

const hasToken = shareToken.trim().length > 0;
const titleValid = isShareableTitle(title);

// Reset transient state whenever the dialog is (re)opened so a prior result or
// error never lingers into a new share. Seed the title from the current
// project name, but leave it blank when the project still has its default
Expand All @@ -98,6 +168,48 @@ export function ShareProjectDialog({
}
}, [open, currentTitle]);

// Pre-flight the project's data sources when the dialog opens, so the author
// learns that a layer will be empty for everyone else *before* the upload
// rather than when a recipient tells them (if they tell them).
//
// Advisory only: it never gates the Share button. An author sharing an
// intranet map with intranet colleagues is doing the right thing.
useEffect(() => {
if (!open || !hasToken) return;
const controller = new AbortController();
setReadinessState("checking");
setReadiness(null);
// Read the live layers once rather than subscribing: the dialog is modal,
// so the snapshot it opens on is the project that will be uploaded.
const state = useAppStore.getState();
void checkShareReadiness(
{
layers: state.layers,
basemapStyleUrl: state.basemapVisible ? state.basemapStyleUrl : null,
pluginManifestUrls: state.projectPlugins?.manifestUrls ?? [],
// The publish path embeds these layers' features, so their local origin
// costs the recipient nothing. Taken from the same predicate that path
// uses so the two cannot drift.
embeddedLayerIds: new Set(
state.layers.filter(isEmbeddableLocalVectorLayer).map((layer) => layer.id),
Comment thread
giswqs marked this conversation as resolved.
),
basemapLabel: t("share.readinessBasemapLabel"),
pluginLabel: t("share.readinessPluginLabel"),
},
{ signal: controller.signal },
)
.then((report) => {
if (controller.signal.aborted) return;
setReadiness(report);
setReadinessState("idle");
})
.catch(() => {
if (controller.signal.aborted) return;
setReadinessState("failed");
});
return () => controller.abort();
}, [open, hasToken, t]);
Comment thread
giswqs marked this conversation as resolved.
Outdated

// Cancel a pending "copied" reset if the dialog unmounts mid-window.
useEffect(
() => () => {
Expand All @@ -108,9 +220,6 @@ export function ShareProjectDialog({
[],
);

const hasToken = shareToken.trim().length > 0;
const titleValid = isShareableTitle(title);

const handleShare = async () => {
// Guard re-entry synchronously: a second click before the disabled state
// renders would otherwise start a concurrent, non-idempotent upload.
Expand Down Expand Up @@ -301,6 +410,49 @@ export function ShareProjectDialog({
</Select>
</div>

{readinessState === "checking" ? (
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t("share.readinessChecking")}
</p>
) : readinessState === "failed" ? (
<p className="text-xs text-muted-foreground">{t("share.readinessUnavailable")}</p>
) : readiness && readiness.problems.length > 0 ? (
<div role="status" className="space-y-2 rounded-md border p-3 text-sm">
<p className="flex items-center gap-2 font-medium">
<TriangleAlert className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
{t("share.readinessTitle")}
</p>
<p className="text-xs text-muted-foreground">{t("share.readinessNote")}</p>
<ul className="max-h-48 space-y-2 overflow-y-auto">
{readiness.problems.map((item) => {
const copy = readinessCopyKeys(item);
return (
<li key={`${item.layerId ?? item.label}:${item.url}`} className="space-y-0.5">
<p className="truncate font-medium" title={item.url || item.label}>
{item.label}
</p>
<p className="text-xs text-muted-foreground">
{t(copy.reason)}
{copy.advice ? ` ${t(copy.advice)}` : ""}
</p>
</li>
);
})}
</ul>
{readiness.truncated ? (
<p className="text-xs text-muted-foreground">
{t("share.readinessTruncated", { count: readiness.probeCount })}
</p>
) : null}
</div>
) : readiness && readiness.items.length > 0 ? (
<p className="flex items-center gap-2 text-xs text-muted-foreground">
<CircleCheck className="h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
{t("share.readinessAllReachable", { count: readiness.items.length })}
</p>
) : null}

{errorCode === "username-required" ? (
<div
role="alert"
Expand Down
30 changes: 30 additions & 0 deletions apps/geolibre-desktop/src/i18n/locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -1345,6 +1345,36 @@
"errorFallback": "تعذّرت مشاركة المشروع.",
"usernameRequired": "عيّن اسم مستخدم في حسابك على {{shareHost}} قبل المشاركة. افتح إعدادات حسابك لاختيار اسم، ثم حاول مرة أخرى.",
"openAccountSettings": "فتح إعدادات الحساب",
"readinessChecking": "جارٍ التحقق مما إذا كان المستلمون قادرين على تحميل بياناتك…",
"readinessUnavailable": "تعذّر إجراء فحص مصادر البيانات.",
"readinessAllReachable_zero": "تبدو مصادر البيانات البالغ عددها {{count}} قابلة للوصول من قِبل المستلمين.",
"readinessAllReachable_one": "يبدو {{count}} مصدر بيانات قابلًا للوصول من قِبل المستلمين.",
"readinessAllReachable_two": "تبدو مصادر البيانات البالغ عددها {{count}} قابلة للوصول من قِبل المستلمين.",
"readinessAllReachable_few": "تبدو جميع مصادر البيانات البالغ عددها {{count}} قابلة للوصول من قِبل المستلمين.",
"readinessAllReachable_many": "تبدو جميع مصادر البيانات البالغ عددها {{count}} مصدرًا قابلة للوصول من قِبل المستلمين.",
"readinessAllReachable_other": "تبدو جميع مصادر البيانات البالغ عددها {{count}} قابلة للوصول من قِبل المستلمين.",
"readinessTitle": "قد لا تُحمَّل بعض البيانات لدى المستلمين",
"readinessNote": "لا يمنع هذا المشاركة. يفتح المستلمون المشروع في متصفح، حيث قد تتصرف هذه المصادر على نحو مختلف عمّا تفعله لديك.",
"readinessTruncated_zero": "لم يُفحص سوى أول {{count}} من المصادر.",
"readinessTruncated_one": "لم يُفحص سوى أول {{count}} مصدر.",
"readinessTruncated_two": "لم يُفحص سوى أول {{count}} من المصادر.",
"readinessTruncated_few": "لم تُفحص سوى أول {{count}} مصادر.",
"readinessTruncated_many": "لم يُفحص سوى أول {{count}} مصدرًا.",
"readinessTruncated_other": "لم يُفحص سوى أول {{count}} مصدر.",
"readinessBasemapLabel": "خريطة الأساس",
"readinessPluginLabel": "إضافة",
"readinessReasonCredentialStripped": "يستخدم بيانات اعتماد تُزال عند المشاركة.",
"readinessReasonAuthRequired": "رفضت الخدمة طلبًا بلا بيانات اعتماد.",
"readinessReasonCors": "لا يستطيع المتصفح جلب البيانات من هذا المضيف: فهو لا يرسل ترويسات عبر المصادر، أو أنه غير قابل للوصول.",
"readinessReasonNotFound": "أجابت الخدمة بأن العنصر غير موجود، لذا قد يكون الرابط منتهي الصلاحية أو قد انتقل.",
"readinessReasonLocalFile": "يشير إلى ملف على جهازك.",
"readinessReasonPrivateHost": "يشير إلى عنوان شبكة خاصة أو محلية.",
"readinessReasonNoSource": "ليس له مصدر يمكن للمستلم فتحه.",
"readinessReasonUnchecked": "تعذّر فحصه.",
"readinessAdviceCredential": "اجعل الخدمة عامة، أو اطلب من المستلمين تقديم بيانات اعتمادهم الخاصة.",
"readinessAdviceCors": "لا يزال يُحمَّل في تطبيق سطح المكتب، لكن ليس في عارض المتصفح.",
"readinessAdviceNotFound": "حدّث مصدر هذه الطبقة أو استبدله قبل المشاركة.",
"readinessAdviceLocal": "حوّله إلى مصدر مستضاف، أو توقّع أن يرى المستلمون طبقة فارغة.",
"credentialsRemoved": "لم يتم تضمين {{count}} من حقول بيانات الاعتماد. يجب على المستلمين تقديم بيانات اعتمادهم أو استخدام مرجع عبر وسيط."
},
"gallery": {
Expand Down
22 changes: 22 additions & 0 deletions apps/geolibre-desktop/src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1205,6 +1205,28 @@
"errorFallback": "Das Projekt konnte nicht geteilt werden.",
"usernameRequired": "Legen Sie einen Benutzernamen für Ihr {{shareHost}}-Konto fest, bevor Sie teilen. Öffnen Sie Ihre Kontoeinstellungen, um einen auszuwählen, und versuchen Sie es erneut.",
"openAccountSettings": "Kontoeinstellungen öffnen",
"readinessChecking": "Es wird geprüft, ob Empfänger Ihre Daten laden können…",
"readinessUnavailable": "Die Prüfung der Datenquellen konnte nicht ausgeführt werden.",
"readinessAllReachable_one": "{{count}} Datenquelle scheint für Empfänger erreichbar zu sein.",
"readinessAllReachable_other": "Alle {{count}} Datenquellen scheinen für Empfänger erreichbar zu sein.",
"readinessTitle": "Einige Daten werden für Empfänger möglicherweise nicht geladen",
"readinessNote": "Das verhindert das Teilen nicht. Empfänger öffnen das Projekt im Browser, wo sich diese Quellen anders verhalten können als bei Ihnen.",
"readinessTruncated_one": "Nur {{count}} Quelle wurde geprüft.",
"readinessTruncated_other": "Nur die ersten {{count}} Quellen wurden geprüft.",
"readinessBasemapLabel": "Hintergrundkarte",
"readinessPluginLabel": "Plugin",
"readinessReasonCredentialStripped": "Verwendet Anmeldedaten, die beim Teilen entfernt werden.",
"readinessReasonAuthRequired": "Der Dienst hat eine Anfrage ohne Anmeldedaten abgelehnt.",
"readinessReasonCors": "Ein Browser kann diesen Host nicht abrufen: Er sendet keine Cross-Origin-Header oder ist nicht erreichbar.",
"readinessReasonNotFound": "Der Dienst hat „nicht gefunden“ geantwortet, der Link ist also möglicherweise abgelaufen oder wurde verschoben.",
"readinessReasonLocalFile": "Verweist auf eine Datei auf Ihrem Gerät.",
"readinessReasonPrivateHost": "Verweist auf eine private oder lokale Netzwerkadresse.",
"readinessReasonNoSource": "Hat keine Quelle, die ein Empfänger öffnen könnte.",
"readinessReasonUnchecked": "Konnte nicht geprüft werden.",
"readinessAdviceCredential": "Machen Sie den Dienst öffentlich oder weisen Sie Empfänger an, eigene Anmeldedaten anzugeben.",
"readinessAdviceCors": "In der Desktop-App wird es weiterhin geladen, im Browser-Viewer jedoch nicht.",
"readinessAdviceNotFound": "Aktualisieren oder ersetzen Sie die Quelle dieser Ebene vor dem Teilen.",
"readinessAdviceLocal": "Wandeln Sie sie in eine gehostete Quelle um, andernfalls sehen Empfänger eine leere Ebene.",
"credentialsRemoved": "{{count}} Anmeldedatenfeld(er) wurden nicht einbezogen. Empfänger müssen eigene Anmeldedaten angeben oder eine vermittelte Referenz verwenden."
},
"gallery": {
Expand Down
24 changes: 23 additions & 1 deletion apps/geolibre-desktop/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1211,7 +1211,29 @@
"sharing": "Sharing…",
"errorFallback": "Could not share the project.",
"usernameRequired": "Set a username on your {{shareHost}} account before sharing. Open your account settings to choose one, then try again.",
"openAccountSettings": "Open account settings"
"openAccountSettings": "Open account settings",
"readinessChecking": "Checking whether recipients can load your data…",
"readinessUnavailable": "The data source check could not run.",
"readinessAllReachable_one": "{{count}} data source looks reachable for recipients.",
"readinessAllReachable_other": "All {{count}} data sources look reachable for recipients.",
"readinessTitle": "Some data may not load for recipients",
"readinessNote": "This does not block sharing. Recipients open the project in a browser, where these sources can behave differently than they do for you.",
"readinessTruncated_one": "Only the first {{count}} source was checked.",
Comment thread
giswqs marked this conversation as resolved.
Outdated
"readinessTruncated_other": "Only the first {{count}} sources were checked.",
"readinessBasemapLabel": "Basemap",
"readinessPluginLabel": "Plugin",
"readinessReasonCredentialStripped": "Uses a credential that is removed when sharing.",
"readinessReasonAuthRequired": "The service refused a request without credentials.",
"readinessReasonCors": "A browser cannot fetch this host: it sends no cross-origin headers, or it is unreachable.",
"readinessReasonNotFound": "The service answered not found, so the link may have expired or moved.",
"readinessReasonLocalFile": "Points at a file on your machine.",
"readinessReasonPrivateHost": "Points at a private or local network address.",
"readinessReasonNoSource": "Has no source a recipient could open.",
"readinessReasonUnchecked": "Could not be checked.",
"readinessAdviceCredential": "Make the service public, or tell recipients to supply their own credential.",
"readinessAdviceCors": "It still loads in the desktop app, but not in the browser viewer.",
"readinessAdviceNotFound": "Update or replace this layer's source before sharing.",
"readinessAdviceLocal": "Convert it to a hosted source, or expect recipients to see an empty layer."
},
"gallery": {
"title": "Project gallery",
Expand Down
Loading
Loading