mirror of
https://github.com/DrizzleTime/Foxel.git
synced 2026-05-07 02:32:41 +08:00
refactor: Refactor public sharing page
This commit is contained in:
@@ -1,165 +0,0 @@
|
|||||||
import { memo, useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useParams } from 'react-router';
|
|
||||||
import { Card, message, Spin, List, Typography, Button, Empty, Breadcrumb, Input, Form } from 'antd';
|
|
||||||
import { FileOutlined, FolderOutlined, DownloadOutlined } from '@ant-design/icons';
|
|
||||||
import { shareApi, type ShareInfo } from '../api/share';
|
|
||||||
import { type VfsEntry } from '../api/vfs';
|
|
||||||
import { format, parseISO } from 'date-fns';
|
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
|
||||||
|
|
||||||
const PublicSharePage = memo(function PublicSharePage() {
|
|
||||||
const { token } = useParams<{ token: string }>();
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [shareInfo, setShareInfo] = useState<ShareInfo | null>(null);
|
|
||||||
const [entries, setEntries] = useState<VfsEntry[]>([]);
|
|
||||||
const [currentPath, setCurrentPath] = useState('/');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [verified, setVerified] = useState(false);
|
|
||||||
|
|
||||||
const loadData = useCallback(async (p: string, pwd?: string) => {
|
|
||||||
if (!token) return;
|
|
||||||
setLoading(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
let info = shareInfo;
|
|
||||||
if (!info) {
|
|
||||||
info = await shareApi.get(token);
|
|
||||||
setShareInfo(info);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info?.access_type === 'password' && !verified) {
|
|
||||||
// Do not load files until password is verified
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPassword = pwd || password;
|
|
||||||
const listing = await shareApi.listDir(token, p, currentPassword);
|
|
||||||
setEntries(listing.entries || []);
|
|
||||||
setCurrentPath(p);
|
|
||||||
} catch (e: any) {
|
|
||||||
setError(e.message || '加载分享失败');
|
|
||||||
if (e.message === '需要密码') {
|
|
||||||
setVerified(false);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [token, shareInfo, password, verified]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadData(currentPath);
|
|
||||||
}, [loadData, currentPath]);
|
|
||||||
|
|
||||||
const handleEntryClick = (entry: VfsEntry) => {
|
|
||||||
if (entry.is_dir) {
|
|
||||||
const newPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
|
|
||||||
loadData(newPath);
|
|
||||||
} else {
|
|
||||||
// Preview logic can be added here
|
|
||||||
message.info('暂不支持预览');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBreadcrumbClick = (path: string) => {
|
|
||||||
loadData(path);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderBreadcrumb = () => {
|
|
||||||
const parts = currentPath.split('/').filter(Boolean);
|
|
||||||
const items = [{ title: '全部文件', path: '/' }];
|
|
||||||
parts.forEach((part, i) => {
|
|
||||||
const path = '/' + parts.slice(0, i + 1).join('/');
|
|
||||||
items.push({ title: part, path });
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<Breadcrumb>
|
|
||||||
{items.map((item, i) => (
|
|
||||||
<Breadcrumb.Item key={i}>
|
|
||||||
{i === items.length - 1 ? (
|
|
||||||
<span>{item.title}</span>
|
|
||||||
) : (
|
|
||||||
<a onClick={() => handleBreadcrumbClick(item.path)}>{item.title}</a>
|
|
||||||
)}
|
|
||||||
</Breadcrumb.Item>
|
|
||||||
))}
|
|
||||||
</Breadcrumb>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePasswordSubmit = async (values: { password_input: string }) => {
|
|
||||||
if (!token) return;
|
|
||||||
try {
|
|
||||||
await shareApi.verifyPassword(token, values.password_input);
|
|
||||||
setPassword(values.password_input);
|
|
||||||
setVerified(true);
|
|
||||||
setError('');
|
|
||||||
loadData(currentPath, values.password_input);
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e.message || '密码错误');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading && !shareInfo) {
|
|
||||||
return <div style={{ textAlign: 'center', padding: 50 }}><Spin size="large" /></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error && !error.includes('需要密码')) {
|
|
||||||
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description={error} /></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shareInfo?.access_type === 'password' && !verified) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '24px', maxWidth: 400, margin: '100px auto' }}>
|
|
||||||
<Card title="需要密码">
|
|
||||||
<Form onFinish={handlePasswordSubmit}>
|
|
||||||
<Form.Item name="password_input" rules={[{ required: true, message: '请输入密码' }]}>
|
|
||||||
<Input.Password placeholder="请输入密码" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item>
|
|
||||||
<Button type="primary" htmlType="submit" block>
|
|
||||||
确认
|
|
||||||
</Button>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '24px', maxWidth: 960, margin: 'auto' }}>
|
|
||||||
<Card>
|
|
||||||
<Title level={4}>{shareInfo?.name}</Title>
|
|
||||||
<Text type="secondary">
|
|
||||||
创建于 {shareInfo && format(parseISO(shareInfo.created_at), 'yyyy-MM-dd')}
|
|
||||||
{shareInfo?.expires_at && `,将于 ${format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd')} 过期`}
|
|
||||||
</Text>
|
|
||||||
<div style={{ margin: '16px 0' }}>
|
|
||||||
{renderBreadcrumb()}
|
|
||||||
</div>
|
|
||||||
<List
|
|
||||||
loading={loading}
|
|
||||||
dataSource={entries}
|
|
||||||
renderItem={item => (
|
|
||||||
<List.Item
|
|
||||||
actions={[
|
|
||||||
!item.is_dir ? <Button type="text" icon={<DownloadOutlined />} href={shareApi.downloadUrl(token!, (currentPath === '/' ? '' : currentPath) + '/' + item.name, password)} download /> : null
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<List.Item.Meta
|
|
||||||
avatar={item.is_dir ? <FolderOutlined /> : <FileOutlined />}
|
|
||||||
title={<a onClick={() => handleEntryClick(item)}>{item.name}</a>}
|
|
||||||
description={!item.is_dir ? `${(item.size / 1024).toFixed(2)} KB` : ''}
|
|
||||||
/>
|
|
||||||
</List.Item>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default PublicSharePage;
|
|
||||||
110
web/src/pages/PublicSharePage/DirectoryViewer.tsx
Normal file
110
web/src/pages/PublicSharePage/DirectoryViewer.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { memo, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Card, message, List, Typography, Button, Empty, Breadcrumb } from 'antd';
|
||||||
|
import { FileOutlined, FolderOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||||
|
import { shareApi, type ShareInfo } from '../../api/share';
|
||||||
|
import { type VfsEntry } from '../../api/vfs';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
interface DirectoryViewerProps {
|
||||||
|
token: string;
|
||||||
|
shareInfo: ShareInfo;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DirectoryViewer = memo(function DirectoryViewer({ token, shareInfo, password }: DirectoryViewerProps) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [entries, setEntries] = useState<VfsEntry[]>([]);
|
||||||
|
const [currentPath, setCurrentPath] = useState('/');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const loadData = useCallback(async (p: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const listing = await shareApi.listDir(token, p, password);
|
||||||
|
setEntries(listing.entries || []);
|
||||||
|
setCurrentPath(p);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || '加载分享失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token, password]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData(currentPath);
|
||||||
|
}, [loadData, currentPath]);
|
||||||
|
|
||||||
|
const handleEntryClick = (entry: VfsEntry) => {
|
||||||
|
if (entry.is_dir) {
|
||||||
|
const newPath = (currentPath === '/' ? '' : currentPath) + '/' + entry.name;
|
||||||
|
loadData(newPath);
|
||||||
|
} else {
|
||||||
|
message.info('暂不支持预览');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBreadcrumbClick = (path: string) => {
|
||||||
|
loadData(path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBreadcrumb = () => {
|
||||||
|
const parts = currentPath.split('/').filter(Boolean);
|
||||||
|
const items = [{ title: '全部文件', path: '/' }];
|
||||||
|
parts.forEach((part, i) => {
|
||||||
|
const path = '/' + parts.slice(0, i + 1).join('/');
|
||||||
|
items.push({ title: part, path });
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Breadcrumb>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<Breadcrumb.Item key={i}>
|
||||||
|
{i === items.length - 1 ? (
|
||||||
|
<span>{item.title}</span>
|
||||||
|
) : (
|
||||||
|
<a onClick={() => handleBreadcrumbClick(item.path)}>{item.title}</a>
|
||||||
|
)}
|
||||||
|
</Breadcrumb.Item>
|
||||||
|
))}
|
||||||
|
</Breadcrumb>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description={error} /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px', maxWidth: 960, margin: 'auto' }}>
|
||||||
|
<Card>
|
||||||
|
<Title level={4}>{shareInfo?.name}</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
创建于 {shareInfo && format(parseISO(shareInfo.created_at), 'yyyy-MM-dd')}
|
||||||
|
{shareInfo?.expires_at && `,将于 ${format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd')} 过期`}
|
||||||
|
</Text>
|
||||||
|
<div style={{ margin: '16px 0' }}>
|
||||||
|
{renderBreadcrumb()}
|
||||||
|
</div>
|
||||||
|
<List
|
||||||
|
loading={loading}
|
||||||
|
dataSource={entries}
|
||||||
|
renderItem={item => (
|
||||||
|
<List.Item
|
||||||
|
actions={[
|
||||||
|
!item.is_dir ? <Button type="text" icon={<DownloadOutlined />} href={shareApi.downloadUrl(token!, (currentPath === '/' ? '' : currentPath) + '/' + item.name, password)} download /> : null
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={item.is_dir ? <FolderOutlined /> : <FileOutlined />}
|
||||||
|
title={<a onClick={() => handleEntryClick(item)}>{item.name}</a>}
|
||||||
|
description={!item.is_dir ? `${(item.size / 1024).toFixed(2)} KB` : ''}
|
||||||
|
/>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
102
web/src/pages/PublicSharePage/FileViewer.tsx
Normal file
102
web/src/pages/PublicSharePage/FileViewer.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { memo, useState, useEffect } from 'react';
|
||||||
|
import { Card, Spin, Button, Typography, Empty } from 'antd';
|
||||||
|
import { DownloadOutlined } from '@ant-design/icons';
|
||||||
|
import { shareApi, type ShareInfo } from '../../api/share';
|
||||||
|
import { type VfsEntry } from '../../api/vfs';
|
||||||
|
import { format, parseISO } from 'date-fns';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
interface FileViewerProps {
|
||||||
|
token: string;
|
||||||
|
shareInfo: ShareInfo;
|
||||||
|
entry: VfsEntry;
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FileViewer = memo(function FileViewer({ token, shareInfo, entry, password }: FileViewerProps) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [content, setContent] = useState<string>('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadFileContent = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const url = shareApi.downloadUrl(token, entry.name, password);
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('无法加载文件');
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
setContent(text);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || '加载文件失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (entry.name.endsWith('.md')) {
|
||||||
|
loadFileContent();
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token, entry.name, password]);
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (loading) {
|
||||||
|
return <div style={{ textAlign: 'center', padding: 50 }}><Spin /></div>;
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return <Empty description={error} />;
|
||||||
|
}
|
||||||
|
if (entry.name.endsWith('.md')) {
|
||||||
|
return <ReactMarkdown>{content}</ReactMarkdown>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Empty
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<p>暂不支持在线预览此类型文件</p>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
href={shareApi.downloadUrl(token, entry.name, password)}
|
||||||
|
download
|
||||||
|
>
|
||||||
|
下载文件
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px', maxWidth: 960, margin: 'auto' }}>
|
||||||
|
<Card>
|
||||||
|
<Title level={4}>{entry.name}</Title>
|
||||||
|
<Text type="secondary">
|
||||||
|
创建于 {shareInfo && format(parseISO(shareInfo.created_at), 'yyyy-MM-dd')}
|
||||||
|
{shareInfo?.expires_at && `,将于 ${format(parseISO(shareInfo.expires_at), 'yyyy-MM-dd')} 过期`}
|
||||||
|
</Text>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Button
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
href={shareApi.downloadUrl(token, entry.name, password)}
|
||||||
|
download
|
||||||
|
>
|
||||||
|
下载
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
{renderContent()}
|
||||||
|
</Card>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
109
web/src/pages/PublicSharePage/index.tsx
Normal file
109
web/src/pages/PublicSharePage/index.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { memo, useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useParams } from 'react-router';
|
||||||
|
import { Card, message, Spin, Button, Empty, Input, Form } from 'antd';
|
||||||
|
import { shareApi, type ShareInfo } from '../../api/share';
|
||||||
|
import { type VfsEntry } from '../../api/vfs';
|
||||||
|
import { DirectoryViewer } from './DirectoryViewer';
|
||||||
|
import { FileViewer } from './FileViewer';
|
||||||
|
|
||||||
|
const PublicSharePage = memo(function PublicSharePage() {
|
||||||
|
const { token } = useParams<{ token: string }>();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [shareInfo, setShareInfo] = useState<ShareInfo | null>(null);
|
||||||
|
const [entry, setEntry] = useState<VfsEntry | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [verified, setVerified] = useState(false);
|
||||||
|
|
||||||
|
const loadData = useCallback(async (pwd?: string) => {
|
||||||
|
if (!token) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
let info = shareInfo;
|
||||||
|
if (!info) {
|
||||||
|
info = await shareApi.get(token);
|
||||||
|
setShareInfo(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info?.access_type === 'password' && !verified) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPassword = pwd || password;
|
||||||
|
|
||||||
|
if (info.paths.length === 1) {
|
||||||
|
const listing = await shareApi.listDir(token, '/', currentPassword);
|
||||||
|
if (listing.entries.length === 1) {
|
||||||
|
const singleEntry = listing.entries[0];
|
||||||
|
setEntry(singleEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(e.message || '加载分享失败');
|
||||||
|
if (e.message === '需要密码') {
|
||||||
|
setVerified(false);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [token, shareInfo, password, verified]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const handlePasswordSubmit = async (values: { password_input: string }) => {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
await shareApi.verifyPassword(token, values.password_input);
|
||||||
|
setPassword(values.password_input);
|
||||||
|
setVerified(true);
|
||||||
|
setError('');
|
||||||
|
loadData(values.password_input);
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e.message || '密码错误');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !shareInfo) {
|
||||||
|
return <div style={{ textAlign: 'center', padding: 50 }}><Spin size="large" /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error && !error.includes('需要密码')) {
|
||||||
|
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description={error} /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shareInfo?.access_type === 'password' && !verified) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '24px', maxWidth: 400, margin: '100px auto' }}>
|
||||||
|
<Card title="需要密码">
|
||||||
|
<Form onFinish={handlePasswordSubmit}>
|
||||||
|
<Form.Item name="password_input" rules={[{ required: true, message: '请输入密码' }]}>
|
||||||
|
<Input.Password placeholder="请输入密码" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" block>
|
||||||
|
确认
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shareInfo) {
|
||||||
|
return <div style={{ textAlign: 'center', padding: 50 }}><Empty description="无法加载分享信息" /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry && !entry.is_dir) {
|
||||||
|
return <FileViewer token={token!} shareInfo={shareInfo} entry={entry} password={password} />;
|
||||||
|
} else {
|
||||||
|
return <DirectoryViewer token={token!} shareInfo={shareInfo} password={password} />;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default PublicSharePage;
|
||||||
@@ -3,7 +3,7 @@ import type { RouteObject } from 'react-router';
|
|||||||
import LayoutShell from './LayoutShell.tsx';
|
import LayoutShell from './LayoutShell.tsx';
|
||||||
import LoginPage from '../pages/LoginPage.tsx';
|
import LoginPage from '../pages/LoginPage.tsx';
|
||||||
import SetupPage from '../pages/SetupPage.tsx';
|
import SetupPage from '../pages/SetupPage.tsx';
|
||||||
import PublicSharePage from '../pages/PublicSharePage.tsx';
|
import PublicSharePage from '../pages/PublicSharePage';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import type { JSX } from 'react';
|
import type { JSX } from 'react';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user