Files
jiang13-forum/frontend/src/components/FriendLinkApplyDialog.tsx

277 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react';
import { Link2, Upload } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { cn } from '@/lib/utils';
import { api } from '../api/client';
import type { FriendLinkApply } from '../api/types';
import { getCachedSiteBranding } from '../hooks/useSiteBranding';
import { resolveFriendLinkLogo } from '../utils/friendLink';
import FriendLinkSiteInfo from './FriendLinkSiteInfo';
type LinkPlacement = 'homepage' | 'custom';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
editApply?: FriendLinkApply | null;
onSubmitted?: () => void;
}
/** 友链申请 / 修改弹窗 */
export default function FriendLinkApplyDialog({ open, onOpenChange, editApply, onSubmitted }: Props) {
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [logo, setLogo] = useState('');
const [linkPlacement, setLinkPlacement] = useState<LinkPlacement>('homepage');
const [reciprocalPageURL, setReciprocalPageURL] = useState('');
const [submitting, setSubmitting] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const isEdit = !!editApply?.id;
const reset = () => {
setName('');
setUrl('');
setLogo('');
setLinkPlacement('homepage');
setReciprocalPageURL('');
};
useEffect(() => {
if (!open) return;
if (editApply) {
setName(editApply.name ?? '');
setUrl(editApply.url ?? '');
setLogo(editApply.logo ?? '');
const onHomepage = editApply.link_on_homepage !== false;
setLinkPlacement(onHomepage ? 'homepage' : 'custom');
setReciprocalPageURL(onHomepage ? '' : (editApply.reciprocal_page_url ?? ''));
} else {
reset();
}
}, [open, editApply]);
const handleOpenChange = (next: boolean) => {
if (!next) reset();
onOpenChange(next);
};
const uploadLogo = async (file: File | undefined) => {
if (!file) return;
setUploadingLogo(true);
try {
const r = await api.uploadFriendLinkLogo(file);
setLogo(r.url);
notify.success('LOGO 已上传');
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '上传失败');
} finally {
setUploadingLogo(false);
if (fileRef.current) fileRef.current.value = '';
}
};
const submit = async () => {
const trimmedName = name.trim();
const trimmedURL = url.trim();
const trimmedLogo = logo.trim();
const trimmedReciprocal = reciprocalPageURL.trim();
if (!trimmedName || !trimmedURL) {
notify.warning('请填写网站名称与网站链接');
return;
}
if (!/^https?:\/\//i.test(trimmedURL)) {
notify.warning('网站链接需以 http:// 或 https:// 开头');
return;
}
if (!trimmedLogo) {
notify.warning('请填写或上传网站 LOGO');
return;
}
if (linkPlacement === 'custom') {
if (!trimmedReciprocal) {
notify.warning('请填写添加本站链接的页面地址');
return;
}
if (!/^https?:\/\//i.test(trimmedReciprocal)) {
notify.warning('回链页地址需以 http:// 或 https:// 开头');
return;
}
}
const linkOnHomepage = linkPlacement === 'homepage';
const body = {
name: trimmedName,
url: trimmedURL,
logo: trimmedLogo,
link_on_homepage: linkOnHomepage,
reciprocal_page_url: linkOnHomepage ? trimmedURL : trimmedReciprocal,
};
setSubmitting(true);
try {
if (isEdit) {
const r = await api.updateFriendLinkApply(editApply!.id, body);
notify.success(
editApply?.status === 'approved'
? `已更新并重新提交审核,友链已暂时从列表移除${r.message.includes('回链检测') ? ',回链检测将在后台进行' : ''}`
: r.message,
);
} else {
const r = await api.applyFriendLink(body);
notify.success(r.message);
}
onSubmitted?.();
handleOpenChange(false);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '提交失败');
} finally {
setSubmitting(false);
}
};
const logoPreview = resolveFriendLinkLogo(logo.trim(), getCachedSiteBranding().site_url);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="friend-link-apply-dialog sm:max-w-[560px]">
<DialogHeader className="friend-link-apply-dialog__header">
<DialogTitle>{isEdit ? '修改友链申请' : '申请本页友情链接'}</DialogTitle>
</DialogHeader>
<FriendLinkSiteInfo />
<div className="friend-link-apply-form">
<div className="friend-link-apply-field">
<Label htmlFor="friend-link-name"></Label>
<Input
id="friend-link-name"
value={name}
onChange={e => setName(e.target.value)}
placeholder="请输入您的网站名称"
maxLength={32}
/>
</div>
<div className="friend-link-apply-field">
<Label htmlFor="friend-link-url"></Label>
<Input
id="friend-link-url"
value={url}
onChange={e => setUrl(e.target.value)}
placeholder="请输入您的网站地址(以 http 开头)"
maxLength={512}
/>
</div>
<div className="friend-link-apply-field">
<Label></Label>
<div className="friend-link-apply-placement" role="radiogroup" aria-label="本站链接放置位置">
<button
type="button"
role="radio"
aria-checked={linkPlacement === 'homepage'}
className={cn(
'friend-link-apply-placement__option',
linkPlacement === 'homepage' && 'friend-link-apply-placement__option--active',
)}
onClick={() => setLinkPlacement('homepage')}
>
</button>
<button
type="button"
role="radio"
aria-checked={linkPlacement === 'custom'}
className={cn(
'friend-link-apply-placement__option',
linkPlacement === 'custom' && 'friend-link-apply-placement__option--active',
)}
onClick={() => setLinkPlacement('custom')}
>
</button>
</div>
</div>
{linkPlacement === 'custom' && (
<div className="friend-link-apply-field">
<Label htmlFor="friend-link-reciprocal"></Label>
<Input
id="friend-link-reciprocal"
value={reciprocalPageURL}
onChange={e => setReciprocalPageURL(e.target.value)}
placeholder="如https://您的域名/link.htm"
maxLength={512}
/>
<p className="friend-link-apply-field__hint">
</p>
</div>
)}
<div className="friend-link-apply-field">
<Label htmlFor="friend-link-logo"> LOGO</Label>
<div className="friend-link-apply-logo-row">
<div className="friend-link-apply-logo-preview" aria-label="LOGO 预览">
{logoPreview ? (
<img src={logoPreview} alt="" loading="lazy" decoding="async" />
) : (
<span></span>
)}
</div>
<Input
id="friend-link-logo"
className="friend-link-apply-logo-input"
value={logo}
onChange={e => setLogo(e.target.value)}
placeholder="LOGO 图片地址"
maxLength={512}
/>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
className="sr-only"
onChange={e => uploadLogo(e.target.files?.[0])}
/>
<Button
type="button"
variant="outline"
className="friend-link-apply-logo-upload"
disabled={uploadingLogo}
onClick={() => fileRef.current?.click()}
>
{uploadingLogo ? <Spinner size="sm" /> : <Upload size={15} aria-hidden />}
</Button>
</div>
</div>
</div>
<DialogFooter className="friend-link-apply-dialog__footer">
<p className="friend-link-apply-dialog__note">
<Link2 size={14} aria-hidden />
</p>
<div className="friend-link-apply-dialog__actions">
<Button type="button" variant="outline" onClick={() => handleOpenChange(false)}>
</Button>
<Button type="button" disabled={submitting || uploadingLogo} onClick={submit}>
{submitting ? '提交中…' : isEdit ? '保存并提交' : '提交申请'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}