feat: 添加Docker支持并优化SEO和用户认证
refactor: 重构页面元数据以支持SEO规范链接 feat(web): 实现用户积分系统和登录验证 docs: 添加Docker使用指南和更新README build: 添加Docker相关配置文件和脚本 chore: 更新依赖项并添加初始化SQL文件
This commit is contained in:
47
web/.dockerignore
Normal file
47
web/.dockerignore
Normal file
@@ -0,0 +1,47 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Node.js
|
||||
node_modules
|
||||
.next/cache
|
||||
dist
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Testing
|
||||
/coverage
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
drizzle
|
||||
.claude/
|
||||
/.public/
|
||||
|
||||
# Docker specific
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
142
web/init.sql
Normal file
142
web/init.sql
Normal file
@@ -0,0 +1,142 @@
|
||||
--
|
||||
-- SQLiteStudio v3.4.17 生成的文件,周三 8月 20 17:58:34 2025
|
||||
--
|
||||
-- 所用的文本编码:System
|
||||
--
|
||||
PRAGMA foreign_keys = off;
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
-- 表:__drizzle_migrations
|
||||
CREATE TABLE IF NOT EXISTS __drizzle_migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hash TEXT NOT NULL,
|
||||
created_at NUMERIC
|
||||
);
|
||||
|
||||
|
||||
-- 表:account
|
||||
CREATE TABLE IF NOT EXISTS account (
|
||||
id TEXT PRIMARY KEY
|
||||
NOT NULL,
|
||||
account_id TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
id_token TEXT,
|
||||
access_token_expires_at INTEGER,
|
||||
refresh_token_expires_at INTEGER,
|
||||
scope TEXT,
|
||||
password TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (
|
||||
user_id
|
||||
)
|
||||
REFERENCES user (id) ON UPDATE NO ACTION
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- 表:points_accounts
|
||||
CREATE TABLE IF NOT EXISTS points_accounts (-- 账户ID,使用自增整数作为主键,效率最高。
|
||||
account_id INTEGER PRIMARY KEY AUTOINCREMENT,-- 关联的用户ID,设置为 TEXT 类型以匹配您的设计。
|
||||
/* 添加 UNIQUE 约束确保一个用户只有一个积分账户。 */user_id TEXT NOT NULL
|
||||
UNIQUE,-- 当前总积分,非负,默认为 0。
|
||||
total_points INTEGER NOT NULL
|
||||
DEFAULT 0
|
||||
CHECK (total_points >= 0),-- 最后更新时间,使用 TEXT 存储 ISO8601 格式的日期时间。
|
||||
/* 在记录更新时,应由应用程序逻辑来更新此字段。 */updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
|
||||
-- 表:points_transactions
|
||||
CREATE TABLE IF NOT EXISTS points_transactions (-- 流水ID,使用自增整数主键。
|
||||
transaction_id INTEGER PRIMARY KEY AUTOINCREMENT,-- 关联的用户ID。
|
||||
user_id TEXT NOT NULL,-- 本次变动的积分数,正数代表增加,负数代表减少。
|
||||
points_change INTEGER NOT NULL,-- 变动原因代码,方便程序进行逻辑判断。
|
||||
reason_code TEXT NOT NULL,-- 变动原因的文字描述,可为空。
|
||||
description TEXT,-- 记录创建时间,默认为当前时间戳。
|
||||
created_at TEXT NOT NULL
|
||||
DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
|
||||
-- 表:session
|
||||
CREATE TABLE IF NOT EXISTS session (
|
||||
id TEXT PRIMARY KEY
|
||||
NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
token TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
user_id TEXT NOT NULL,
|
||||
FOREIGN KEY (
|
||||
user_id
|
||||
)
|
||||
REFERENCES user (id) ON UPDATE NO ACTION
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
|
||||
-- 表:user
|
||||
CREATE TABLE IF NOT EXISTS user (
|
||||
id TEXT PRIMARY KEY
|
||||
NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
email_verified INTEGER NOT NULL,
|
||||
image TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
username TEXT,
|
||||
display_username TEXT
|
||||
);
|
||||
|
||||
|
||||
-- 表:verification
|
||||
CREATE TABLE IF NOT EXISTS verification (
|
||||
id TEXT PRIMARY KEY
|
||||
NOT NULL,
|
||||
identifier TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
);
|
||||
|
||||
|
||||
-- 索引:idx_points_accounts_user_id
|
||||
CREATE INDEX IF NOT EXISTS idx_points_accounts_user_id ON points_accounts (
|
||||
user_id
|
||||
);
|
||||
|
||||
|
||||
-- 索引:idx_points_transactions_user_id
|
||||
CREATE INDEX IF NOT EXISTS idx_points_transactions_user_id ON points_transactions (
|
||||
user_id
|
||||
);
|
||||
|
||||
|
||||
-- 索引:session_token_unique
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS session_token_unique ON session (
|
||||
token
|
||||
);
|
||||
|
||||
|
||||
-- 索引:user_email_unique
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_email_unique ON user (
|
||||
email
|
||||
);
|
||||
|
||||
|
||||
-- 索引:user_username_unique
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS user_username_unique ON user (
|
||||
username
|
||||
);
|
||||
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
PRAGMA foreign_keys = on;
|
||||
@@ -8,6 +8,7 @@ const nextConfig = {
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
78
web/package-lock.json
generated
78
web/package-lock.json
generated
@@ -24,6 +24,7 @@
|
||||
"dotenv": "^17.2.1",
|
||||
"drizzle-orm": "^0.44.4",
|
||||
"framer-motion": "^11.3.8",
|
||||
"globby": "^14.1.0",
|
||||
"lucide-react": "^0.424.0",
|
||||
"next": "^14.2.5",
|
||||
"postcss": "^8.4.40",
|
||||
@@ -2520,6 +2521,18 @@
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sindresorhus/merge-streams": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz",
|
||||
"integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
@@ -6000,6 +6013,35 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/globby": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/globby/-/globby-14.1.0.tgz",
|
||||
"integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sindresorhus/merge-streams": "^2.1.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"ignore": "^7.0.3",
|
||||
"path-type": "^6.0.0",
|
||||
"slash": "^5.1.0",
|
||||
"unicorn-magic": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/globby/node_modules/ignore": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
|
||||
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/goober": {
|
||||
"version": "2.1.16",
|
||||
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz",
|
||||
@@ -8234,6 +8276,18 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz",
|
||||
"integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
@@ -9432,6 +9486,18 @@
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/slash": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
|
||||
"integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.1",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
|
||||
@@ -10336,6 +10402,18 @@
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicorn-magic": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz",
|
||||
"integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/unified": {
|
||||
"version": "11.0.5",
|
||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||
|
||||
BIN
web/sqlite.db
Normal file
BIN
web/sqlite.db
Normal file
Binary file not shown.
@@ -8,6 +8,9 @@ import { AiOutlineTikTok, AiFillQqCircle, AiOutlineGithub, AiOutlineTwitter, AiF
|
||||
export const metadata: Metadata = {
|
||||
title: '联系我们 - PodcastHub',
|
||||
description: '有任何问题或建议?请随时联系 PodcastHub 团队。我们期待您的声音。',
|
||||
alternates: {
|
||||
canonical: '/contact',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,8 +10,9 @@ const inter = Inter({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'PodcastHub - 给创意一个真实的声音',
|
||||
description: '使用AI技术将您的想法和内容转换为高质量的播客音频,支持多种语音和风格选择。',
|
||||
metadataBase: new URL('https://www.podcasthub.com'),
|
||||
title: 'PodcastHub: 您的AI播客创作平台 - 轻松将文字转化为高质量播客音频,支持多种语音和风格,让创意触手可及',
|
||||
description: 'PodcastHub 利用尖端AI技术,为您的创意提供无限可能。轻松将文字和想法转化为专业品质的播客音频,支持多种个性化语音和风格选择。立即体验高效创作,让您的声音在全球范围内传播,吸引更多听众,并简化您的播客制作流程。',
|
||||
keywords: ['播客', 'AI', '语音合成', 'TTS', '音频生成'],
|
||||
authors: [{ name: 'PodcastHub Team' }],
|
||||
icons: {
|
||||
@@ -19,11 +20,18 @@ export const metadata: Metadata = {
|
||||
apple: '/favicon.webp',
|
||||
},
|
||||
openGraph: {
|
||||
title: 'PodcastHub - 给创意一个真实的声音',
|
||||
description: '使用AI技术将您的想法和内容转换为高质量的播客音频,支持多种语音和风格选择。',
|
||||
title: 'PodcastHub: 您的AI播客创作平台 - 轻松将文字转化为高质量播客音频,支持多种语音和风格,让创意触手可及',
|
||||
description: 'PodcastHub 利用尖端AI技术,为您的创意提供无限可能。轻松将文字和想法转化为专业品质的播客音频,支持多种个性化语音和风格选择。立即体验高效创作,让您的声音在全球范围内传播,吸引更多听众,并简化您的播客制作流程。',
|
||||
type: 'website',
|
||||
locale: 'zh_CN',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'PodcastHub: 您的AI播客创作平台 - 轻松将文字转化为高质量播客音频,支持多种语音和风格,让创意触手可及',
|
||||
},
|
||||
alternates: {
|
||||
canonical: '/',
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport = {
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { Metadata } from 'next';
|
||||
import PodcastContent from '@/components/PodcastContent';
|
||||
|
||||
export async function generateMetadata({ params }: PodcastDetailPageProps): Promise<Metadata> {
|
||||
const fileName = decodeURIComponent(params.fileName);
|
||||
const title = `播客详情 - ${fileName}`;
|
||||
const description = `收听 ${fileName} 的播客。`;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: `/podcast/${fileName}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface PodcastDetailPageProps {
|
||||
params: {
|
||||
fileName: string;
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import PricingSection from '@/components/PricingSection'; // 导入 PricingSection 组件
|
||||
|
||||
const PricingPage: React.FC = () => {
|
||||
return (
|
||||
<PricingSection />
|
||||
);
|
||||
import { Metadata } from 'next';
|
||||
|
||||
import React from 'react';
|
||||
import PricingSection from '@/components/PricingSection'; // 导入 PricingSection 组件
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '定价 - PodcastHub',
|
||||
description: '查看 PodcastHub 的灵活定价方案,找到最适合您的播客创作计划。',
|
||||
alternates: {
|
||||
canonical: '/pricing',
|
||||
},
|
||||
};
|
||||
|
||||
export default PricingPage;
|
||||
|
||||
const PricingPage: React.FC = () => {
|
||||
return (
|
||||
<PricingSection />
|
||||
);
|
||||
};
|
||||
|
||||
export default PricingPage;
|
||||
@@ -7,6 +7,9 @@ import { Metadata } from 'next';
|
||||
export const metadata: Metadata = {
|
||||
title: '隐私政策 - PodcastHub',
|
||||
description: '了解 PodcastHub 如何保护您的隐私。我们致力于透明化地处理您的数据。',
|
||||
alternates: {
|
||||
canonical: '/privacy',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,9 @@ import { Metadata } from 'next';
|
||||
export const metadata: Metadata = {
|
||||
title: '使用条款 - PodcastHub',
|
||||
description: '欢迎了解 PodcastHub 的使用条款。本条款旨在保护用户与平台的共同利益。',
|
||||
alternates: {
|
||||
canonical: '/terms',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,8 +16,10 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import ConfigSelector from './ConfigSelector';
|
||||
import VoicesModal from './VoicesModal'; // 引入 VoicesModal
|
||||
import LoginModal from './LoginModal'; // 引入 LoginModal
|
||||
import { useToast, ToastContainer } from './Toast'; // 引入 Toast Hook 和 Container
|
||||
import { setItem, getItem } from '@/lib/storage'; // 引入 localStorage 工具
|
||||
import { useSession } from '@/lib/auth-client'; // 引入 useSession
|
||||
import type { PodcastGenerationRequest, TTSConfig, Voice, SettingsFormData } from '@/types';
|
||||
import { Satisfy } from 'next/font/google'; // 导入艺术字体 Satisfy
|
||||
|
||||
@@ -72,6 +74,7 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
|
||||
const [language, setLanguage] = useState(languageOptions[0].value);
|
||||
const [duration, setDuration] = useState(durationOptions[0].value);
|
||||
const [showVoicesModal, setShowVoicesModal] = useState(false); // 新增状态
|
||||
const [showLoginModal, setShowLoginModal] = useState(false); // 控制登录模态框的显示
|
||||
const [voices, setVoices] = useState<Voice[]>([]); // 从 ConfigSelector 获取 voices
|
||||
const [selectedPodcastVoices, setSelectedPodcastVoices] = useState<{[key: string]: Voice[]}>(() => {
|
||||
// 从 localStorage 读取缓存的说话人配置
|
||||
@@ -83,8 +86,13 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { toasts, error } = useToast(); // 使用 useToast hook, 引入 success
|
||||
const { data: session } = useSession(); // 获取 session
|
||||
|
||||
const handleSubmit = async () => { // 修改为 async 函数
|
||||
if (!session?.user) { // 判断是否登录
|
||||
setShowLoginModal(true); // 未登录则显示登录模态框
|
||||
return;
|
||||
}
|
||||
if (!topic.trim()) {
|
||||
error("主题不能为空", "请输入播客主题。"); // 使用 toast.error
|
||||
return;
|
||||
@@ -206,9 +214,9 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl text-black mb-6 break-words">
|
||||
<h1 className="text-2xl sm:text-3xl text-black mb-6 break-words">
|
||||
给创意一个真实的声音
|
||||
</h2>
|
||||
</h1>
|
||||
|
||||
{/* 模式切换按钮 todo */}
|
||||
{/* <div className="flex items-center justify-center gap-2 sm:gap-4 mb-8 flex-wrap">
|
||||
@@ -387,10 +395,10 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
|
||||
{/* 创作按钮 */}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!topic.trim() || isGenerating || credits <= 0}
|
||||
disabled={!topic.trim() || isGenerating}
|
||||
className={cn(
|
||||
"btn-primary flex items-center gap-1 text-sm sm:text-base px-3 py-2 sm:px-4 sm:py-2",
|
||||
(!topic.trim() || isGenerating || credits <= 0) && "opacity-50 cursor-not-allowed"
|
||||
(!topic.trim() || isGenerating) && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{isGenerating ? (
|
||||
@@ -440,6 +448,11 @@ const PodcastCreator: React.FC<PodcastCreatorProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Login Modal */}
|
||||
<LoginModal
|
||||
isOpen={showLoginModal}
|
||||
onClose={() => setShowLoginModal(false)}
|
||||
/>
|
||||
|
||||
<ToastContainer toasts={toasts} onRemove={() => {}} /> {/* 添加 ToastContainer */}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user