import { ActionIcon, Box, Button, Group, Modal, Text, TextInput, } from "@mantine/core"; import { IconExternalLink } from "@tabler/icons-react"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import CopyTextButton from "@/components/common/copy.tsx"; import { getAppUrl } from "@/lib/config.ts"; import { useRemoveShareAliasMutation, useSetShareAliasMutation, useShareAliasForPageQuery, } from "@/features/share/queries/share-query.ts"; import { checkShareAliasAvailability } from "@/features/share/services/share-service.ts"; import { isValidShareAlias, normalizeShareAlias, } from "@/features/share/share-alias.util.ts"; interface ShareAliasSectionProps { pageId: string; readOnly: boolean; } // The prefix label shown next to the slug input, e.g. "docs.example.com/l/". function aliasPrefixLabel(): string { const url = getAppUrl(); const host = url.replace(/^https?:\/\//, "").replace(/\/+$/, ""); return `${host}/l/`; } export default function ShareAliasSection({ pageId, readOnly, }: ShareAliasSectionProps) { const { t } = useTranslation(); const { data: currentAlias } = useShareAliasForPageQuery(pageId); const setAliasMutation = useSetShareAliasMutation(); const removeAliasMutation = useRemoveShareAliasMutation(); const [value, setValue] = useState(""); const [availability, setAvailability] = useState<{ valid: boolean; available: boolean; currentPageId: string | null; } | null>(null); const [reassign, setReassign] = useState<{ alias: string; currentPageTitle: string | null; } | null>(null); // Seed the input from the page's current alias (if any). useEffect(() => { setValue(currentAlias?.alias ?? ""); }, [currentAlias?.alias, pageId]); const normalized = useMemo(() => normalizeShareAlias(value), [value]); const isValid = isValidShareAlias(normalized); const unchanged = currentAlias?.alias === normalized; // Debounced availability probe (skips when invalid or unchanged). const debounceRef = useRef>(); useEffect(() => { setAvailability(null); if (!isValid || unchanged) return; debounceRef.current && clearTimeout(debounceRef.current); debounceRef.current = setTimeout(async () => { try { const res = await checkShareAliasAvailability(normalized); setAvailability({ valid: res.valid, available: res.available, currentPageId: res.currentPageId, }); } catch { setAvailability(null); } }, 400); return () => { debounceRef.current && clearTimeout(debounceRef.current); }; }, [normalized, isValid, unchanged]); const prettyLink = currentAlias?.alias ? `${getAppUrl()}/l/${currentAlias.alias}` : null; const handleSave = async (confirmReassign = false) => { try { await setAliasMutation.mutateAsync({ pageId, alias: normalized, confirmReassign, }); setReassign(null); } catch (error: any) { // The address already points at another page: prompt to move it here. if (error?.status === 409 || error?.response?.status === 409) { const data = error?.response?.data; if (data?.code === "ALIAS_REASSIGN_REQUIRED") { setReassign({ alias: normalized, currentPageTitle: data?.currentPageTitle ?? null, }); } } } }; const handleRemove = async () => { if (!currentAlias?.id) return; await removeAliasMutation.mutateAsync(currentAlias.id); setValue(""); }; const showInvalid = normalized.length > 0 && !isValid; // The typed name is already in use by ANOTHER page. This is NOT a dead end: // hitting Save triggers the server's 409 `ALIAS_REASSIGN_REQUIRED` and opens // the "Move custom address?" confirm modal that retargets the address here. // So surface it as an informational hint (not a terminal red error) and keep // Save enabled, instead of looking like the address is unusable. const reassignable = isValid && !unchanged && !!availability && !availability.available; // The slug prefix (e.g. "docs.example.com/l/") is static for the session. const prefixLabel = aliasPrefixLabel(); const prefixRef = useRef(null); const [prefixWidth, setPrefixWidth] = useState(0); // Measure the real rendered width of the prefix so the slug input sits flush // next to it, instead of after an over-estimated character-counted gap. useLayoutEffect(() => { if (prefixRef.current) { setPrefixWidth(Math.ceil(prefixRef.current.scrollWidth) + 1); } }, [prefixLabel]); return ( <> {t("Custom address")} {t("A short, memorable link you can point at any shared page.")} {prettyLink && ( } style={{ width: "100%" }} /> )} setValue(e.currentTarget.value)} // Show the canonical form once the user pauses so what they type maps // visibly to what gets stored. onBlur={() => setValue(normalized)} leftSection={ {prefixLabel} } leftSectionWidth={prefixWidth || undefined} placeholder={t("my-page")} disabled={readOnly} error={ showInvalid ? t("Use 2-60 lowercase letters, digits and hyphens") : undefined } description={ reassignable ? t("This address is in use. Saving will move it to this page.") : undefined } /> {currentAlias?.id && ( )} setReassign(null)} title={t("Move custom address?")} centered size="sm" > {reassign?.currentPageTitle ? t( 'The address "{{alias}}" currently points to "{{title}}". Move it to this page?', { alias: reassign?.alias, title: reassign?.currentPageTitle, }, ) : t( 'The address "{{alias}}" is already in use. Move it to this page?', { alias: reassign?.alias }, )} ); }