feat(share): custom /l/:alias pretty links (share_aliases table) (#205)
Add a retargetable, human-readable vanity link namespace /l/<alias> that sits alongside the untouched /share/... routes. - New share_aliases table (workspace-scoped, UNIQUE(workspace_id, alias), page_id nullable ON DELETE SET NULL so the address outlives its target). - ShareAliasRepo + ShareAliasService (create / no-op / 409 reassign guard / availability / request-time readable-target resolution through the single existing share boundary). - Public ShareAliasRedirectController (GET /l/:alias) issues a 302 (never 301, the target is mutable) to the canonical /share/:key/p/:slug page; unknown / dangling / no-longer-readable aliases serve the SPA index with no leak. 'l/:alias' excluded from the global /api prefix. - Authenticated ShareAliasController (set/remove/availability/for-page). - Shared ASCII-only normalize/validate util (server + client copies). - Client: Custom address block in the share modal (live normalize + debounced availability + copy + reassign confirmation dialog). - Unit tests: util, repo SQL-shape, service semantics, migration/entity sanity (server jest) + client alias util (vitest). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
import { useEffect, 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<ReturnType<typeof setTimeout>>();
|
||||
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;
|
||||
const showTaken =
|
||||
isValid && !unchanged && availability && !availability.available;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text size="sm" fw={500} mt="md">
|
||||
{t("Custom address")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
{t("A short, memorable link you can point at any shared page.")}
|
||||
</Text>
|
||||
|
||||
{prettyLink && (
|
||||
<Group my="xs" gap={4} wrap="nowrap">
|
||||
<TextInput
|
||||
variant="filled"
|
||||
value={prettyLink}
|
||||
readOnly
|
||||
rightSection={<CopyTextButton text={prettyLink} />}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<ActionIcon
|
||||
component="a"
|
||||
variant="default"
|
||||
target="_blank"
|
||||
href={prettyLink}
|
||||
size="sm"
|
||||
>
|
||||
<IconExternalLink size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
value={value}
|
||||
onChange={(e) => 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={
|
||||
<Text size="xs" c="dimmed" pl={4} style={{ whiteSpace: "nowrap" }}>
|
||||
{aliasPrefixLabel()}
|
||||
</Text>
|
||||
}
|
||||
leftSectionWidth={Math.min(aliasPrefixLabel().length * 7 + 12, 180)}
|
||||
placeholder={t("my-page")}
|
||||
disabled={readOnly}
|
||||
error={
|
||||
showInvalid
|
||||
? t("Use 2-60 lowercase letters, digits and hyphens")
|
||||
: showTaken
|
||||
? t("This address is already in use")
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<Group mt="xs" gap="xs">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
onClick={() => handleSave(false)}
|
||||
loading={setAliasMutation.isPending}
|
||||
disabled={readOnly || !isValid || unchanged}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
{currentAlias?.id && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
color="red"
|
||||
onClick={handleRemove}
|
||||
loading={removeAliasMutation.isPending}
|
||||
disabled={readOnly}
|
||||
>
|
||||
{t("Remove")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Modal
|
||||
opened={!!reassign}
|
||||
onClose={() => setReassign(null)}
|
||||
title={t("Move custom address?")}
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Text 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 },
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={() => setReassign(null)}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => handleSave(true)}
|
||||
loading={setAliasMutation.isPending}
|
||||
>
|
||||
{t("Move here")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import CopyTextButton from "@/components/common/copy.tsx";
|
||||
import { getAppUrl } from "@/lib/config.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import classes from "@/features/share/components/share.module.css";
|
||||
import ShareAliasSection from "@/features/share/components/share-alias-section.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
@@ -253,6 +254,9 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
disabled={readOnly}
|
||||
/>
|
||||
</Group>
|
||||
{pageId && (
|
||||
<ShareAliasSection pageId={pageId} readOnly={readOnly} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user