前端备份

This commit is contained in:
david_bai
2025-05-23 22:41:56 +08:00
parent 6347be9925
commit 4d3eb551c7
104 changed files with 18020 additions and 0 deletions
+90
View File
@@ -0,0 +1,90 @@
"use client";
import ClipboardApp from '@/components/ClipboardApp'
import { cn } from "@/lib/utils"
import SystemDiagram from '@/components/web/SystemDiagram'
import FAQSection from '@/components/web/FAQSection'
import HowItWorks from '@/components/web/HowItWorks'
import YouTubePlayer from '@/components/self_define/YouTubePlayer';
import KeyFeatures from '@/components/web/KeyFeatures'
import type { Messages } from '@/types/messages';
interface PageContentProps {
messages: Messages;
lang:string;
}
export default function HomeClient({ messages,lang }: PageContentProps) {
const youtube_videoId = lang==="zh"?"I0RLCpcbUXs":"ypt-po_R2Ds";
const bilibili_videoId = lang==="zh"?"BV1knrjYZEfn":"BV1yErjYFEV7";
return (
<main className="container mx-auto px-4 py-8">
{/* Hero Section */}
<h1 className="text-4xl font-bold mb-2 text-center">
{messages.text.home.h1}
</h1>
<p className="text-xl mb-4 text-center">
{messages.text.home.h1P}
</p>
{/* App Section */}
<section id="clipboard-app" className="py-12" aria-label="File Transfer Application">
<div className="container mx-auto px-4">
{/* sr-only--screen-only:视觉不可见 */}
<h2 className={cn("sr-only", "text-3xl font-bold mb-8 text-center")}>
{messages.text.home.h2_screenOnly}
</h2>
<ClipboardApp />
</div>
</section>
{/* Demo Video Section */}
<section className="mb-12" aria-label="Product Demo">
<h2 className="text-3xl font-bold mb-6 text-center">
{messages.text.home.h2_demo}
</h2>
<p className="text-center mb-6 text-gray-600">
{messages.text.home.h2P_demo}
</p>
<YouTubePlayer videoId={youtube_videoId} />
<div className="mt-4 text-center">
<p className="mb-3 text-gray-700">
{messages.text.home.watch_tips}
</p>
<a className="flex justify-center gap-4 text-blue-500 hover:underline transition-colors"
href={`https://www.youtube.com/watch?v=${youtube_videoId}`}
target="_blank"
rel="noopener noreferrer"
>
{messages.text.home.youtube_tips}
</a>
<a className="flex justify-center gap-4 text-blue-500 hover:underline transition-colors"
href={`https://www.bilibili.com/video/${bilibili_videoId}`}
target="_blank"
rel="noopener noreferrer"
>
{messages.text.home.bilibili_tips}
</a>
</div>
</section>
{/* How It Works Section */}
<section aria-label="How It Works">
<HowItWorks messages={messages}/>
</section>
{/* System Architecture Section */}
<section aria-label="System Architecture">
<SystemDiagram messages={messages}/>
</section>
{/* Key Features */}
<section aria-label="Key Features">
<KeyFeatures messages={messages}/>
</section>
{/* FAQ Section */}
<section aria-label="Frequently Asked Questions">
<FAQSection
messages={messages}
isMainPage
titleClassName="text-2xl md:text-3xl" // 可选:在首页使用稍小的字号
/>
</section>
</main>
)
}
@@ -0,0 +1,34 @@
import type { Messages } from '@/types/messages';
interface AboutContentProps {
messages: Messages;
lang: string;
}
export default function AboutContent({ messages,lang }: AboutContentProps) {
return (
<div className="container mx-auto p-6">
<h1 className="text-3xl font-bold text-center mb-6">{messages.text.about.h1}</h1>
<p className="text-lg mb-4">
{messages.text.about.P1}
</p>
<p className="text-lg mb-4">
{messages.text.about.P2}
</p>
<p className="text-lg mb-4">
{messages.text.about.P3}
</p>
<p className="text-lg mb-4">
{messages.text.about.P4}
</p>
<p className="text-lg mb-4">
{messages.text.about.P5}
</p>
<ul className="list-disc pl-6">
<li><a href={`/${lang}/privacy`} className="text-blue-500 hover:underline">{messages.text.privacy.PrivacyPolicy_dis}</a></li>
<li><a href={`/${lang}/terms`} className="text-blue-500 hover:underline">{messages.text.terms.TermsOfUse_dis}</a></li>
<li><a href={`/${lang}/help`} className="text-blue-500 hover:underline">{messages.text.help.Help_dis}</a></li>
</ul>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { getDictionary } from '@/lib/dictionary';
import AboutContent from './AboutContent';
import { Metadata } from 'next'
import { supportedLocales } from '@/constants/i18n-config';
export async function generateMetadata({
params
}: {
params: { lang: string }
}): Promise<Metadata> {
const messages = await getDictionary(params.lang);
return {
title: messages.meta.about.title,
description: messages.meta.about.description,
metadataBase: new URL('https://www.securityshare.xyz'),
alternates: {
canonical: `/${params.lang}/about`,
languages: Object.fromEntries(
supportedLocales.map(lang => [lang, `/${lang}/about`])
),
},
openGraph: {
title: messages.meta.about.title,
description: messages.meta.about.description,
url: `https://www.securityshare.xyz/${params.lang}/about`,
siteName: 'SecureShare',
locale: params.lang,
type: 'website',
},
};
}
export default async function About({
params: { lang }
}: {
params: { lang: string }
}) {
const messages = await getDictionary(lang);
return <AboutContent messages={messages} lang={lang}/>;
}
@@ -0,0 +1,42 @@
// app/[lang]/blog/[slug]/metadata.ts
import { Metadata } from "next"
import { getPostBySlug } from '@/lib/blog'
import { generateMetadata as generateBlogMetadata } from '../metadata'
export async function generateMetadata({
params
}: {
params: { slug: string; lang: string }
}): Promise<Metadata> {
const post = await getPostBySlug(params.slug)
if (!post) {//blog not found
// 调用博客首页的 generateMetadata 函数并传入参数
return generateBlogMetadata({ params: { lang: params.lang } })
}
return {
title: `${post.frontmatter.title} | SecureShare Blog`,
description: post.frontmatter.description,
keywords: `${post.frontmatter.tags.join(', ')}, secure file sharing, p2p transfer, privacy`,
metadataBase: new URL('https://www.securityshare.xyz'),
alternates: {
canonical: `/${params.lang}/blog/${params.slug}`,
languages: {
en: `/en/blog/${params.slug}`,
zh: `/zh/blog/${params.slug}`,
}
},
openGraph: {
title: post.frontmatter.title,
description: post.frontmatter.description,
url: `https://www.securityshare.xyz/${params.lang}/blog/${params.slug}`,
siteName: 'SecureShare',
locale: params.lang,
type: 'article',
publishedTime: post.frontmatter.date,
modifiedTime: post.frontmatter.date,
authors: post.frontmatter.author,
},
}
}
+63
View File
@@ -0,0 +1,63 @@
//文章详情页
import { MDXRemote } from 'next-mdx-remote/rsc'
import { getPostBySlug } from '@/lib/blog'
import * as React from 'react'
import { mdxOptions } from '@/lib/mdx-config';
import { mdxComponents } from '@/components/blog/MDXComponents';
import { TableOfContents } from '@/components/blog/TableOfContents'
import { generateMetadata } from './metadata'
export { generateMetadata }
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug)
if (!post) {
return <div>Post not found</div>
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
{/* 使用 md: 前缀来处理中等屏幕及以上的flex布局 */}
<div className="block md:flex md:gap-8">
{/* 文章内容区域 */}
<article className="w-full md:flex-1 max-w-4xl">
<header className="mb-8">
<h1 className="text-3xl sm:text-4xl font-bold mb-4 text-gray-900">
{post.frontmatter.title}
</h1>
<div className="flex flex-wrap items-center text-gray-600 gap-2 sm:gap-4">
<time className="text-sm">
{new Date(post.frontmatter.date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
<span className="hidden sm:inline">·</span>
<span className="text-sm">
by <span className="font-bold">{post.frontmatter.author}</span>
</span>
</div>
</header>
<div className="prose prose-sm sm:prose lg:prose-lg max-w-none">
<MDXRemote
source={post.content}
components={{
...mdxComponents,
wrapper: ({ children }) => (
<div className="space-y-4 text-gray-700 overflow-x-auto">
{children}
</div>
),
}}
options={mdxOptions}
/>
</div>
</article>
<TableOfContents content={post.content} />
</div>
</div>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { supportedLocales } from '@/constants/i18n-config';
import { Metadata } from 'next'
export async function generateMetadata({
params
}: {
params: { lang: string }
}): Promise<Metadata> {
return {
title: 'SecureShare Blog - Private P2P File Sharing & Collaboration',
description: 'Discover secure file sharing tips, privacy-focused collaboration strategies, and how to leverage P2P technology for safer data transfer. Learn about WebRTC, end-to-end encryption, and team collaboration.',
keywords: 'secure file sharing, p2p file transfer, private collaboration, webrtc, end-to-end encryption, team collaboration, privacy tools',
metadataBase: new URL('https://www.securityshare.xyz'),
alternates: {
canonical: `/${params.lang}/blog`,
languages: {
en: '/en/blog',
zh: '/zh/blog',
}
},
openGraph: {
title: 'SecureShare Blog - Private P2P File Sharing & Collaboration',
description: 'Explore secure file sharing, private collaboration tools, and data privacy best practices. Join our community of privacy-conscious professionals.',
url: `https://www.securityshare.xyz/${params.lang}/blog`,
siteName: 'SecureShare',
locale: params.lang,
type: 'website'
},
}
}
+76
View File
@@ -0,0 +1,76 @@
import { getAllPosts } from '@/lib/blog'
import { ArticleListItem } from '@/components/blog/ArticleListItem'
import Link from 'next/link';
import { slugifyTag } from '@/utils/tagUtils'
import { generateMetadata } from './metadata'
export { generateMetadata }
export default async function BlogPage({
params: { lang }
}: {
params: { lang: string }
}) {
const posts = await getAllPosts(lang)
return (
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-12"> {/* 增大最大宽度和内边距 */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12"> {/* 增大列间距 */}
{/* Main Content */}
<main className="lg:col-span-8">
<div className="mb-12"> {/* 增大标题区域间距 */}
<h1 className="text-4xl font-bold mb-4">Blog</h1> {/* 增大标题字号 */}
<p className="text-gray-600 text-lg">Latest articles and updates</p>
</div>
{/* Articles List */}
<div className="space-y-12"> {/* 增大文章间距 */}
{posts.map((post) => (
<ArticleListItem key={post.slug} post={post} />
))}
</div>
</main>
{/* Sidebar */}
<aside className="lg:col-span-4">
<div className="sticky top-8">
{/* Recent Posts */}
<div className="bg-white rounded-xl shadow-lg p-8 mb-8"> {/* 修改圆角和内边距 */}
<h2 className="text-xl font-bold mb-6">Recent Posts</h2>
<div className="space-y-4">
{posts.slice(0, 5).map((post) => (
<Link
key={post.slug}
href={`/en/blog/${post.slug}`}
className="block hover:text-blue-600 text-base font-medium"
>
{post.frontmatter.title}
</Link>
))}
</div>
</div>
{/* tags */}
<div className="bg-white rounded-xl shadow-lg p-8">
<h2 className="text-xl font-bold mb-6">Tags</h2>
<div className="space-y-3">
{/* 获取所有标签并去重 */}
{Array.from(new Set(posts.flatMap(p => p.frontmatter.tags))).map((tag) => (
<Link
key={tag}
href={`/${lang}/blog/tag/${slugifyTag(tag)}`} // 跳转到标签过滤页面
className="flex items-center justify-between hover:text-blue-600"
>
<span className="text-gray-700 font-medium">{tag}</span>
<span className="bg-gray-100 px-3 py-1 rounded-full text-sm text-gray-600">
{posts.filter(p => p.frontmatter.tags.includes(tag)).length}
</span>
</Link>
))}
</div>
</div>
</div>
</aside>
</div>
</div>
)
}
@@ -0,0 +1,68 @@
import { Metadata } from 'next'
import { getPostsByTag } from '@/lib/blog'
import { ArticleListItem } from '@/components/blog/ArticleListItem'
import { supportedLocales } from '@/constants/i18n-config';
import { unslugifyTag } from '@/utils/tagUtils'
export async function generateMetadata({
params: { tag, lang }
}: {
params: { tag: string; lang: string }
}): Promise<Metadata> {
const decodedTag = unslugifyTag(tag);
return {
title: `${decodedTag} - SecureShare Blog Articles`,
description: `Explore articles about ${decodedTag} - Learn about secure file sharing, private collaboration, and data privacy solutions related to ${decodedTag}`,
keywords: `${decodedTag}, secure file sharing, p2p file transfer, privacy, collaboration, webrtc`,
metadataBase: new URL('https://www.securityshare.xyz'),
alternates: {
canonical: `/${lang}/blog/tag/${encodeURIComponent(tag)}`,
languages: {
en: `/en/blog/tag/${encodeURIComponent(tag)}`,
zh: `/zh/blog/tag/${encodeURIComponent(tag)}`,
}
},
openGraph: {
title: `${decodedTag} - SecureShare Blog Articles`,
description: `Discover articles about ${decodedTag} - Expert insights on secure file sharing and private collaboration solutions`,
url: `https://www.securityshare.xyz/${lang}/blog/tag/${encodeURIComponent(tag)}`,
siteName: 'SecureShare',
locale: lang,
type: 'website',
},
}
}
export default async function TagPage({
params: { tag, lang }
}: {
params: { tag: string; lang: string }
}) {
const decodedTag = unslugifyTag(tag);
const posts = await getPostsByTag(decodedTag,lang)
return (
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12">
{/* Main Content */}
<main className="lg:col-span-8">
<div className="mb-12">
<h1 className="text-4xl font-bold mb-4">Tag: {decodedTag}</h1>
<p className="text-gray-600 text-lg">Articles tagged with {decodedTag}</p>
</div>
{/* Articles List */}
<div className="space-y-12">
{posts.length > 0 ? (
posts.map((post) => (
<ArticleListItem key={post.slug} post={post} />
))
) : (
<p>No articles found for this decodedTag.</p>
)}
</div>
</main>
</div>
</div>
)
}
+44
View File
@@ -0,0 +1,44 @@
import FAQSection from '@/components/web/FAQSection'
import type { Metadata } from "next";
import { getDictionary } from '@/lib/dictionary';
import { supportedLocales } from '@/constants/i18n-config';
export async function generateMetadata({
params
}: {
params: { lang: string }
}): Promise<Metadata> {
const messages = await getDictionary(params.lang);
return {
title: messages.meta.faq.title,
description: messages.meta.faq.description,
keywords: messages.meta.faq.keywords,
metadataBase: new URL('https://www.securityshare.xyz'),
alternates: {
canonical: `/${params.lang}/faq`,
languages: Object.fromEntries(
supportedLocales.map(lang => [lang, `/${lang}/faq`])
),
},
openGraph: {
title: messages.meta.faq.title,
description: messages.meta.faq.description,
url: `https://www.securityshare.xyz/${params.lang}/faq`,
siteName: 'SecureShare',
locale: params.lang,
type: 'website',
},
};
}
export default async function FAQ({
params: { lang }
}: {
params: { lang: string }
}) {
const messages = await getDictionary(lang);
return (
<FAQSection messages={messages} />
)
}
+138
View File
@@ -0,0 +1,138 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* Custom prose styles */
.prose {
@apply text-gray-600;
}
.prose h1, .prose h2, .prose h3, .prose h4 {
@apply text-gray-900 font-bold mt-8 mb-4;
}
.prose h1 {
@apply text-3xl;
}
.prose h2 {
@apply text-2xl;
}
.prose h3 {
@apply text-xl;
}
.prose p {
@apply mb-6 leading-relaxed;
}
.prose ul, .prose ol {
@apply my-6 ml-6;
}
.prose li {
@apply mb-2;
}
.prose code {
@apply text-sm bg-gray-50 rounded px-1.5 py-0.5 text-gray-800 border border-gray-200;
}
.prose pre {
@apply my-6 p-4 bg-gray-50 rounded-lg overflow-x-auto border border-gray-200;
}
.prose pre code {
@apply bg-transparent text-gray-800 p-0 border-0;
}
.prose blockquote {
@apply border-l-4 border-blue-500 pl-4 my-6 italic text-gray-600;
}
.prose table {
@apply min-w-full divide-y divide-gray-200 my-6;
}
.prose th {
@apply px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
}
.prose td {
@apply px-6 py-4 whitespace-nowrap text-sm text-gray-500;
}
.prose img {
@apply rounded-lg my-6;
}
.prose figure figcaption {
@apply text-center text-sm text-gray-600 mt-2 italic;
}
+43
View File
@@ -0,0 +1,43 @@
import type { Messages } from '@/types/messages';
interface HelpContentProps {
messages: Messages;
lang: string;
}
export default function HelpContent({ messages,lang }: HelpContentProps) {
return (
<div className="container mx-auto py-12">
<h1 className="text-4xl font-bold mb-6">{messages.text.help.h1}</h1>
<p className="text-lg mb-4">
{messages.text.help.h1_P}
</p>
<h2 className="text-2xl font-bold mb-4">{messages.text.help.h2_1}</h2>
<p className="text-lg mb-4">
{messages.text.help.h2_1_P1} {" "}
<a href="mailto:david.vision66@gmail.com" className="text-blue-500 hover:underline">david.vision66@gmail.com</a>
{messages.text.help.h2_1_P2}
</p>
<h2 className="text-2xl font-bold mb-4">{messages.text.help.h2_2}</h2>
<p className="text-lg mb-4">
{messages.text.help.h2_2_P}
</p>
<ul className="list-disc pl-6">
<li><a href="https://x.com/David_vision66" className="text-blue-500 hover:underline">Twitter</a></li>
{/* <li><a href="https://www.facebook.com/secureshare" className="text-blue-500 hover:underline">Facebook</a></li>
<li><a href="https://www.linkedin.com/company/secureshare" className="text-blue-500 hover:underline">LinkedIn</a></li> */}
</ul>
<h2 className="text-2xl font-bold mb-4">{messages.text.help.h2_3}</h2>
<p className="text-lg mb-4">
{messages.text.help.h2_3_P}
</p>
<ul className="list-disc pl-6">
<li><a href={`/${lang}/about`} className="text-blue-500 hover:underline">{messages.text.about.h1}</a></li>
<li><a href={`/${lang}/terms`} className="text-blue-500 hover:underline">{messages.text.terms.TermsOfUse_dis}</a></li>
<li><a href={`/${lang}/privacy`} className="text-blue-500 hover:underline">{messages.text.privacy.PrivacyPolicy_dis}</a></li>
</ul>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { getDictionary } from '@/lib/dictionary';
import HelpContent from './HelpContent';
import { Metadata } from 'next'
import { supportedLocales } from '@/constants/i18n-config';
export async function generateMetadata({
params
}: {
params: { lang: string }
}): Promise<Metadata> {
const messages = await getDictionary(params.lang);
return {
title: messages.meta.help.title,
description: messages.meta.help.description,
metadataBase: new URL('https://www.securityshare.xyz'),
alternates: {
canonical: `/${params.lang}/help`,
languages: Object.fromEntries(
supportedLocales.map(lang => [lang, `/${lang}/help`])
),
},
openGraph: {
title: messages.meta.help.title,
description: messages.meta.help.description,
url: `https://www.securityshare.xyz/${params.lang}/help`,
siteName: 'SecureShare',
locale: params.lang,
type: 'website',
},
};
}
export default async function Help({
params: { lang }
}: {
params: { lang: string }
}) {
const messages = await getDictionary(lang);
return <HelpContent messages={messages} lang={lang} />;
}
+52
View File
@@ -0,0 +1,52 @@
import "./globals.css";
import Header from '@/components/web/Header'
import Footer from '@/components/web/Footer';
import { ThemeProvider } from "@/components/web/theme-provider";
import Script from 'next/script';
import { getDictionary } from '@/lib/dictionary';
export default async function RootLayout({
children,
params: { lang }
}: Readonly<{
children: React.ReactNode,
params: { lang: string }
}>) {
const messages = await getDictionary(lang);
const googleAnalyticsId = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS;
return (
<html lang={lang} className="h-full" suppressHydrationWarning>
<head />
<body className="min-h-full flex flex-col">
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
storageKey="theme-preference"
>
<Header messages={messages} lang={lang} />
<div className="flex-1">
{children}
</div>
<Footer messages={messages} lang={lang} />
</ThemeProvider>
{/* Google Analytics */}
<Script
src={`https://www.googletagmanager.com/gtag/js?id=${googleAnalyticsId}`}
strategy="afterInteractive" // 脚本在页面加载后执行
/>
<Script id="google-analytics" strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${googleAnalyticsId}');
`}
</Script>
</body>
</html>
);
}
+44
View File
@@ -0,0 +1,44 @@
import HomeClient from './HomeClient';
import { getDictionary } from '@/lib/dictionary';
import { Metadata } from 'next'
import { supportedLocales } from '@/constants/i18n-config';
export async function generateMetadata({
params
}: {
params: { lang: string }
}): Promise<Metadata> {
const messages = await getDictionary(params.lang);
return {
title: messages.meta.home.title,
description: messages.meta.home.description,
keywords: messages.meta.home.keywords,
metadataBase: new URL('https://www.securityshare.xyz'),
alternates: {
canonical: `/${params.lang}`,
languages: Object.fromEntries(
supportedLocales.map(lang => [lang, `/${lang}`])
),
},
//OpenGraph元数据可以优化社交媒体分享
openGraph: {
title: messages.meta.home.title,
description: messages.meta.home.description,
url: `https://www.securityshare.xyz/${params.lang}`,
siteName: 'SecureShare',
locale: params.lang,
type: 'website',
},
}
}
export default async function Home({
params: { lang }
}: {
params: { lang: string }
}) {
const messages = await getDictionary(lang);
return <HomeClient messages={messages} lang={lang}/>;
}
@@ -0,0 +1,36 @@
import type { Messages } from '@/types/messages';
interface PageContentProps {
messages: Messages;
}
export default function PrivacyContent({ messages }: PageContentProps){
return (
<div className="container mx-auto p-6">
<h1 className="text-3xl font-bold text-center mb-6">{messages.text.privacy.h1}</h1>
<p className="text-lg mb-4">
{messages.text.privacy.h1_P}
</p>
<h2 className="text-2xl font-bold mb-4">{messages.text.privacy.h2_1}</h2>
<p className="text-lg mb-4">
{messages.text.privacy.h2_1_P}
</p>
<h2 className="text-2xl font-bold mb-4">{messages.text.privacy.h2_2}</h2>
<p className="text-lg mb-4">
{messages.text.privacy.h2_2_P}
</p>
<h2 className="text-2xl font-bold mb-4">{messages.text.privacy.h2_3}</h2>
<p className="text-lg mb-4">
{messages.text.privacy.h2_3_P}
</p>
<h2 className="text-2xl font-bold mb-4">{messages.text.privacy.h2_4}</h2>
<p className="text-lg mb-4">
{messages.text.privacy.h2_4_P}
</p>
<h2 className="text-2xl font-bold mb-4">{messages.text.privacy.h2_5}</h2>
<p className="text-lg mb-4">
{messages.text.privacy.h2_5_P} <a href="mailto:david.vision66@gmail.com" className="text-blue-500 hover:underline">david.vision66@gmail.com</a>.
</p>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import type { Metadata } from "next";
import { getDictionary } from '@/lib/dictionary';
import PrivacyContent from './PrivacyContent';
import { supportedLocales } from '@/constants/i18n-config';
export async function generateMetadata({
params
}: {
params: { lang: string }
}): Promise<Metadata> {
const messages = await getDictionary(params.lang);
return {
title: messages.meta.privacy.title,
description: messages.meta.privacy.description,
metadataBase: new URL('https://www.securityshare.xyz'),
alternates: {
canonical: `/${params.lang}/privacy`,
languages: Object.fromEntries(
supportedLocales.map(lang => [lang, `/${lang}/privacy`])
),
},
openGraph: {
title: messages.meta.privacy.title,
description: messages.meta.privacy.description,
url: `https://www.securityshare.xyz/${params.lang}/privacy`,
siteName: 'SecureShare',
locale: params.lang,
type: 'website',
},
};
}
export default async function Privacy({
params: { lang }
}: {
params: { lang: string }
}) {
const messages = await getDictionary(lang);
return <PrivacyContent messages={messages} />;
}
@@ -0,0 +1,36 @@
import type { Messages } from '@/types/messages';
interface PageContentProps {
messages: Messages;
}
export default function TermsContent({ messages }: PageContentProps){
return (
<div className="container mx-auto p-6">
<h1 className="text-3xl font-bold text-center mb-6">{messages.text.terms.h1}</h1>
<p className="text-lg mb-4">
{messages.text.terms.h1_P}
</p>
<h2 className="text-2xl font-bold mb-4">{messages.text.terms.h2_1}</h2>
<p className="text-lg mb-4">
{messages.text.terms.h2_1_P}
</p>
<h2 className="text-2xl font-bold mb-4">{messages.text.terms.h2_2}</h2>
<p className="text-lg mb-4">
{messages.text.terms.h2_2_P}
</p>
<h2 className="text-2xl font-bold mb-4">{messages.text.terms.h2_3}</h2>
<p className="text-lg mb-4">
{messages.text.terms.h2_3_P}
</p>
<h2 className="text-2xl font-bold mb-4">{messages.text.terms.h2_4}</h2>
<p className="text-lg mb-4">
{messages.text.terms.h2_4_P}
</p>
<h2 className="text-2xl font-bold mb-4">{messages.text.terms.h2_5}</h2>
<p className="text-lg mb-4">
{messages.text.terms.h2_5_P}
</p>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { getDictionary } from '@/lib/dictionary';
import TermsContent from './TermsContent';
import { Metadata } from 'next'
import { supportedLocales } from '@/constants/i18n-config';
export async function generateMetadata({
params
}: {
params: { lang: string }
}): Promise<Metadata> {
const messages = await getDictionary(params.lang);
return {
title: messages.meta.terms.title,
description: messages.meta.terms.description,
metadataBase: new URL('https://www.securityshare.xyz'),
alternates: {
canonical: `/${params.lang}/terms`,
languages: Object.fromEntries(
supportedLocales.map(lang => [lang, `/${lang}/terms`])
),
},
openGraph: {
title: messages.meta.terms.title,
description: messages.meta.terms.description,
url: `https://www.securityshare.xyz/${params.lang}/terms`,
siteName: 'SecureShare',
locale: params.lang,
type: 'website',
},
};
}
export default async function TermsOfUse({
params: { lang }
}: {
params: { lang: string }
}) {
const messages = await getDictionary(lang);
return <TermsContent messages={messages} />;
}
+93
View File
@@ -0,0 +1,93 @@
import { config, getFetchOptions } from './environment';
const API_URL = process.env.NEXT_PUBLIC_API_URL || '';
export const API_ROUTES = {
get_room: `${API_URL}/api/get_room`,
check_room: `${API_URL}/api/check_room`,
creat_room: `${API_URL}/api/creat_room`,
set_track: `${API_URL}/api/set_track`,
logs_debug: `${API_URL}/api/logs_debug`,
};
// 创建房间
export const postLogInDebug = async (message: string) => {
try {
const response = await fetch(
`${API_ROUTES.logs_debug}`,
getFetchOptions({
method: 'POST',
body: JSON.stringify({
message,
timestamp: new Date().toISOString()
}),
})
);
} catch (error) {
console.error('Error creating room:', error);
}
};
export const fetchRoom = async () => {
try {
const response = await fetch(
`${API_ROUTES.get_room}`,
getFetchOptions()
);
const data = await response.json();
return data.roomId;
} catch (err) {
console.error('Error fetching room:', err);
throw err;
}
};
// 创建房间
export const createRoom = async (roomId: string) => {
try {
const response = await fetch(
`${API_ROUTES.creat_room}`,
getFetchOptions({
method: 'POST',
body: JSON.stringify({ roomId }),
})
);
const data = await response.json();
return data.success;
} catch (error) {
console.error('Error creating room:', error);
return false;
}
};
// 检查房间是否可用
export const checkRoom = async (roomId: string) => {
try {
const response = await fetch(
`${API_ROUTES.check_room}`,
getFetchOptions({
method: 'POST',
body: JSON.stringify({ roomId }),
})
);
const data = await response.json();
return data.available;
} catch (error) {
console.error('Error checking room:', error);
return false;
}
};
// 设置追踪信息
export const setTrack = async (ref: string,path: string) => {
try {
const response = await fetch(
`${API_ROUTES.set_track}`,
getFetchOptions({
method: 'POST',
body: JSON.stringify({ ref,path,timestamp: new Date().toISOString() }),
})
);
// const data = await response.json();
// return data.success;
} catch (error) {
console.error('Error checking room:', error);
// return false;
}
};
+54
View File
@@ -0,0 +1,54 @@
export const config = {
API_URL: process.env.NEXT_PUBLIC_API_URL!,
SERVER_IP: process.env.NEXT_PUBLIC_SERVER_IP!,
USE_HTTPS: process.env.NEXT_PUBLIC_USE_HTTPS === 'true',
USE_CREDENTIALS: process.env.NEXT_PUBLIC_USE_CREDENTIALS === 'true',
TURN_USERNAME: "secureUser",
TURN_CREDENTIAL: "QWERTY!@#456",
};
export const getIceServers = () => {
const stunUrl = [`stun:${config.SERVER_IP}:3478`,
"stun:stun.l.google.com:19302"
];
const turnUrls = config.USE_HTTPS
? [
`turn:${config.SERVER_IP}:3478`,
`turns:${config.SERVER_IP}:5349`
]
: [`turn:${config.SERVER_IP}:3478`];
return [
{ urls: stunUrl },
{
urls: turnUrls,
username: config.TURN_USERNAME,
credential: config.TURN_CREDENTIAL
}
];
};
export const getSocketOptions = () => {
return config.USE_HTTPS
? {
secure: true,
path: '/socket.io/',
transports: ['websocket']
}
: undefined;
};
export const getFetchOptions = (options: RequestInit = {}): RequestInit => {
const defaultOptions: RequestInit = {
headers: {
'Content-Type': 'application/json',
},
...options
};
if (config.USE_CREDENTIALS) {
defaultOptions.credentials = 'include';
}
return defaultOptions;
};
+32
View File
@@ -0,0 +1,32 @@
import { MetadataRoute } from 'next'
import { supportedLocales } from '@/constants/i18n-config';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = 'https://www.securityshare.xyz'
const languages = supportedLocales;
const routes = ['', '/about', '/help', '/faq', '/terms', '/privacy']
const urls: MetadataRoute.Sitemap = []
// Add root URL
urls.push({
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
})
// Add language specific URLs
languages.forEach(lang => {
routes.forEach(route => {
urls.push({
url: `${baseUrl}/${lang}${route}`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: route === '' ? 1.0 : 0.8,
})
})
})
return urls
}
+4
View File
@@ -0,0 +1,4 @@
declare module 'lodash';
interface Window {
showDirectoryPicker?: () => Promise<FileSystemDirectoryHandle>;
}
+571
View File
@@ -0,0 +1,571 @@
"use client";
import React, { useState, useEffect , useRef, useCallback,useMemo } from 'react';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import WebRTC_Initiator from '../lib/webrtc_Initiator';
import WebRTC_Recipient from '../lib/webrtc_Recipient';
import FileReceiver from '../lib/fileReceiver';
import FileSender from '../lib/fileSender';
import { debounce } from 'lodash';
import FileListDisplay from './self_define/FileListDisplay';
import {FileMeta,CustomFile,fileMetadata } from '@/lib/types/file';
import {WriteClipboardButton,ReadClipboardButton} from './self_define/clipboard_btn';
import useRichTextToPlainText from './self_define/rich-text-to-plain-text';
import QRCodeComponent from './self_define/RetrieveMethod';
import {FileUploadHandler,DownloadAs} from './self_define/file-upload-handler';
import JSZip from 'jszip';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tooltip } from './Tooltip';
import RichTextEditor from '@/components/Editor/RichTextEditor'
import { config } from '@/app/config/environment';
import { fetchRoom, createRoom, checkRoom } from '@/app/config/api';
import {trackReferrer} from '@/components/utils/tracking'
import { postLogInDebug } from '@/app/config/api';
import AnimatedButton from './self_define/AnimatedButton';
import {format_peopleMsg } from '@/utils/formatMessage';
import { getDictionary } from '@/lib/dictionary';
import { useLocale } from '@/hooks/useLocale';
import type { Messages } from '@/types/messages';
const developmentEnv = process.env.NEXT_PUBLIC_development!;//开发环境
// 处理 beforeunload 事件的函数
const handleBeforeUnload = (event:any) => {
event.preventDefault();
event.returnValue = ''; // This is required for older browsers
};
// 当用户确实想要离开页面时(例如,在保存数据后),可以调用此函数移除事件监听器
function allowUnload() {
window.removeEventListener('beforeunload', handleBeforeUnload);
}
const AdvancedClipboardApp = () => {
const [shareRoomId, setShareRoomId] = useState('');//发送端--房间ID
const [initShareRoomId, setInitShareRoomId] = useState('');//系统随机初始化的房间ID
const [retrieveRoomId, setRetrieveRoomId] = useState('');//接收端--房间ID
const [shareMessage, setShareMessage] = useState('');//发送端--消息显示
const [retrieveMessage, setRetrieveMessage] = useState('');//接收端--消息显示
//发送端:编辑器文本、文件
const [shareContent, setShareContent] = useState('');
const [sendFiles, setSendFiles] = useState<CustomFile[]>([]);//FILE对象只会先引用文件,并不会将文件内容读取进内存。只有当分片读取时,才加载一小片到内存。理论上支持大文件。
const [sendProgress, setSendProgress] = useState<{//文件的进度--发送端--{fileId:0-1}--支持区分多个接收端
[fileId: string]: {
[peerId: string]: { progress: number; speed: number;}
}}>({});
const [receiveProgress, setReceiveProgress] = useState<{//文件的进度--接收端--{fileId:0-1}--目前只有一个发送端(为了和发送进度保持一致)
[fileId: string]: {
[peerId: string]: { progress: number; speed: number;}
}}>({});
// 取回端:编辑器文本、文件
const [retrievedContent, setRetrievedContent] = useState('');
const [retrievedFiles, setRetrievedFiles] = useState<CustomFile[]>([]);
const [retrievedFileMetas, setRetrievedFileMetas] = useState<FileMeta[]>([]);//接收到的meta信息
//初始化 p2p通信/文件传输 对象
const [sender, setSender] = useState<WebRTC_Initiator | null>(null);
const [receiver, setReceiver] = useState<WebRTC_Recipient | null>(null);
const [senderFileTransfer, setSenderFileTransfer] = useState<FileSender | null>(null);
const [receiverFileTransfer, setReceiverFileTransfer] = useState<FileReceiver | null>(null);
const [shareLink, setShareLink] = useState('');//分享链接
const retrieveJoinRoomBtnRef = useRef<HTMLButtonElement>(null);//接收方--加入房间按钮ref
const [activeTab, setActiveTab] = useState('send');//代表tab的当前激活窗口
const richTextToPlainText = useRichTextToPlainText();
// 房间状态--显示
const [shareRoomStatus, setShareRoomStatus] = useState('');
const [retrieveRoomStatus, setRetrieveRoomStatus] = useState('');
// 1. 添加一个状态来追踪连接数量
const [sharePeerCount, setSharePeerCount] = useState(0);
const [retrievePeerCount, setRetrievePeerCount] = useState(0);
const locale = useLocale();
const [messages, setMessages] = useState<Messages | null>(null);
//显示消息一段时间后清除,shareEnd是否是发送端
async function putMessageInMs(message:string,shareEnd=true,displayTime_ms:number=4000) {
if (shareEnd){
setShareMessage(message);
setTimeout(() => setShareMessage(''), displayTime_ms);
}else{
setRetrieveMessage(message);
setTimeout(() => setRetrieveMessage(''), displayTime_ms);
}
}
useEffect(() => {
getDictionary(locale)
.then(dict => setMessages(dict))
.catch(error => console.error('Failed to load messages:', error));
}, [locale]);
// 使用 useEffect 钩子来在组件加载时生成一个随机ID
useEffect(() => {
const initRoom = async () => {
try {
const roomId = await fetchRoom();
setShareRoomId(roomId);
setInitShareRoomId(roomId);
} catch (err) {
console.error('Error fetching room:', err);
putMessageInMs(messages!.text.ClipboardApp.fetchRoom_err);
}
};
initRoom();
}, [messages]);
useEffect(() => {
window.addEventListener('beforeunload', handleBeforeUnload);
if (sendFiles.length==0 && shareContent=='' && retrievedFiles.length==0 && retrievedContent==''){//如果页面不存在任何内容,则不阻止刷新或离开
allowUnload();
}
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
};
}, [sendFiles,shareContent,retrievedContent,retrievedFiles]);
useEffect(() => {
trackReferrer();
// 检查URL中是否包含roomId参数,是--直接切换到取回界面并点击“加入房间”
const urlParams = new URLSearchParams(window.location.search);
const roomIdParam = urlParams.get('roomId');
if (roomIdParam) {
setRetrieveRoomId(roomIdParam);
setActiveTab('retrieve');
// 使用 setTimeout 来确保在 DOM 更新后触发点击
setTimeout(() => {
if (retrieveJoinRoomBtnRef.current) {
retrieveJoinRoomBtnRef.current.click();
}
}, 200);
}
}, []);
const debouncedCheckRoom = useMemo(
() => debounce(async (roomId: string): Promise<boolean> => {
const available = await checkRoom(roomId);
return available;
}, 300),
[]
);
const handleShareRoomCheck = async (roomId: string) => {
if(roomId.length === 0){
putMessageInMs(messages!.text.ClipboardApp.roomCheck.empty_msg);
return;
}
const available = await debouncedCheckRoom(roomId);
if (available) {
putMessageInMs(messages!.text.ClipboardApp.roomCheck.available_msg);
setShareRoomId(roomId);
} else {
putMessageInMs(messages!.text.ClipboardApp.roomCheck.notAvailable_msg);
}
};
//useCallback 钩子来定义 函数。这确保了函数只在其依赖项(content, files, senderFileTransfer)发生变化时才会重新创建
const sendStringAndMetas = useCallback(async (peerId: string) => {
if (!senderFileTransfer) {
console.error('senderFileTransfer is not initialized, delaying send operation...');
// 重试逻辑改为异步
setTimeout(async () => {
if (senderFileTransfer) {
console.log('Retrying send operation...');
if (shareContent) await (senderFileTransfer as FileSender).sendString(shareContent, peerId);
if (sendFiles.length) (senderFileTransfer as FileSender).sendFileMeta(sendFiles, peerId);
}
}, 1000);
return;
}
if (shareContent) {
if (developmentEnv === 'true')postLogInDebug(`Sending string content:${shareContent}`);
// console.log('Sending string content:', shareContent);
await senderFileTransfer.sendString(shareContent, peerId);
}
if (sendFiles.length) {
// console.log('Sending file metadata:', sendFiles);
senderFileTransfer.sendFileMeta(sendFiles, peerId);
}
}, [shareContent, sendFiles, senderFileTransfer]);
// 使用useEffect钩子来 在组件加载时 初始化,并在组件卸载时清理连接
useEffect(() => {
const senderConnection = new WebRTC_Initiator(config.API_URL);
const receiverConnection = new WebRTC_Recipient(config.API_URL);
setSender(senderConnection);
setReceiver(receiverConnection);
const senderFT = new FileSender(senderConnection);
const receiverFT = new FileReceiver(receiverConnection);
console.log('Created file transfer instances');
setSenderFileTransfer(senderFT);
setReceiverFileTransfer(receiverFT);
return () => {
senderConnection.cleanUpBeforeExit();
receiverConnection.cleanUpBeforeExit();
};
}, []);
//定义一些文件接收处理函数
useEffect(() => {
if (sender && senderFileTransfer) {
sender.onConnectionStateChange = (state: RTCPeerConnectionState, peerId: string) => {
console.log(`connection status: ${state} with peerId: ${peerId}`);
setSharePeerCount(sender.peerConnections.size);
if(state === "connected"){//当建立连接后,设置进度回调函数
senderFileTransfer?.setProgressCallback((fileId:string, progress:number, speed:number) => {
setSendProgress(prev => ({
...prev,
[fileId]: {
...prev[fileId],
[peerId]: { progress, speed }
}
}));
}, peerId);
}
};
sender.onDataChannelOpen = sendStringAndMetas;
}
if (receiver && receiverFileTransfer) {
receiver.onConnectionStateChange = (state: string, peerId: string) => {
console.log(`connection status: ${state} with peerId: ${peerId}`);
setRetrievePeerCount(receiver.peerConnections.size);
if(state === "connected"){
receiverFileTransfer?.setProgressCallback((fileId:string, progress:number, speed:number) => {
setReceiveProgress(prev => ({
...prev,
[fileId]: {
...prev[fileId],
[peerId]: { progress, speed }
}
}));
});
}
};
// receiver.onDataChannelOpen = () => {
// putMessageInMs(messages!.text.ClipboardApp.channelOpen_msg,false);
// };
}
if (receiverFileTransfer) {
receiverFileTransfer.onStringReceived = (value: string) => {
setRetrievedContent(value);
};
receiverFileTransfer.onFileMetaReceived = (fileMeta: fileMetadata) => {
const { type, ...metaWithoutType } = fileMeta; // 剔除 type 属性
setRetrievedFileMetas(prev => [...prev, metaWithoutType]);
};
receiverFileTransfer.onFileReceived = async (file:CustomFile) => {
setRetrievedFiles(prev => {
// 检查 fullName 是否已经存在
const isDuplicate = prev.some(existingFile => existingFile.fullName === file.fullName);
if (isDuplicate) {
return prev; // 如果存在,返回原数组
}
return [...prev, file]; // 否则添加到数组中
});
};
}
}, [sender, receiver,senderFileTransfer,receiverFileTransfer, sendStringAndMetas, messages]);
//只有接收端支持下载
const handleDownload = async (meta: FileMeta) => {
if (meta.folderName !== ""){
const downloadFiles = retrievedFiles.filter(file => file.folderName === meta.folderName);
const zip = new JSZip();
for(let file of downloadFiles)
zip.file(file.fullName, file);// Add files to the zip
try {
// Generate the zip file
const content = await zip.generateAsync({ type: 'blob' });
DownloadAs(content,`downloaded_folder_${meta.folderName}.zip`);
} catch (error) {
console.error('Error creating zip file:', error);
// alert('An error occurred while creating the zip file.');
}
}else {
const downloadFiles = retrievedFiles.filter(file => file.name === meta.name);
for(let file of downloadFiles)
DownloadAs(file,file.name);
}
};
const onFilePicked = (files:CustomFile[]) => {
setSendFiles(prevFiles => [...prevFiles, ...files]);
};
//点击删除按钮之后,将对应文件删掉
const removeSenderFile = (meta: FileMeta) => {
let updatedFiles = [];
if (meta.folderName !== ""){
updatedFiles = sendFiles.filter(file => file.folderName !== meta.folderName);
}else {
updatedFiles = sendFiles.filter(file => file.name !== meta.name);
}
setSendFiles(updatedFiles);
};
// 分享内容的处理函数
const handleShare = async () => {
// console.log('handleShare',sender);
if (!sender) return;
if (sender.peerConnections.size === 0) {
setShareMessage(messages!.text.ClipboardApp.waitting_tips);
} else {
// 广播给所有连接方
const peerIds = Array.from(sender.peerConnections.keys());
// 使用 Promise.all 并行发送给所有peer
await Promise.all(peerIds.map(peerId => sendStringAndMetas(peerId)));
}
// 生成分享链接,并展示获取方法
const link = `${window.location.origin}${window.location.pathname}?roomId=${shareRoomId}`;
setShareLink(link);
};
// 加入房间,等有人进入后会自动建立连接
const handleJoinRoom = async (isSender: boolean) => {
if(!sender || !receiver)return;
// 根据 isSender 确定使用的变量
const roomId = isSender ? shareRoomId : retrieveRoomId;
const peer = isSender ? sender : receiver;
// 检查房间 ID
if (!roomId) {
putMessageInMs(messages!.text.ClipboardApp.joinRoom.EmptyMsg,isSender);
return;
}
// 只有发送方能创建房间
if (isSender && activeTab === 'send' && !peer.isInRoom) {
if (roomId !== initShareRoomId){//如果是系统初始化的RoomID,则不需要重复创建房间
const success = await createRoom(roomId);
if (!success) {
putMessageInMs(messages!.text.ClipboardApp.joinRoom.DuplicateMsg,isSender);
return;
}
}
}
try {
await peer.joinRoom(roomId, isSender);
// 成功加入房间后的逻辑
putMessageInMs(messages!.text.ClipboardApp.joinRoom.successMsg,isSender,6000);
// 生成分享链接,并展示获取方法
const link = `${window.location.origin}${window.location.pathname}?roomId=${shareRoomId}`;
setShareLink(link);
} catch (error) {
if (error instanceof Error) {
if (error.message === "Room does not exist") {
putMessageInMs(messages!.text.ClipboardApp.joinRoom.notExist,isSender);
} else {
putMessageInMs(messages!.text.ClipboardApp.joinRoom.failMsg+` ${error.message}`,isSender);
}
console.error('Failed to join room:', error.message);
} else {
console.error('Failed to join room with unknown error', error);
}
// 处理加入房间失败的逻辑
}
};
//选择保存目录
const onLocationPick = async (): Promise<boolean> => {
// 检查浏览器是否支持 showDirectoryPicker
if (!window.showDirectoryPicker) {
console.error("showDirectoryPicker is not supported in this browser.");
return false;
}
// 确认操作
const userConfirmed = window.confirm(messages!.text.ClipboardApp.pickSaveMsg);
if (!userConfirmed) {
return false;
}
try {
// 选择保存目录
const directory = await window.showDirectoryPicker();
if (receiverFileTransfer && directory) {
console.log('onLocationPick',directory);
await receiverFileTransfer.setSaveDirectory(directory);
return true;
} else {
return false;
}
} catch (err) {
console.error("Failed to set up folder receive:", err);
return false;
}
};
const handleRequest = async (meta: FileMeta) => {
if(!receiverFileTransfer)return;
if(meta.folderName){
receiverFileTransfer.requestFolder(meta.folderName);
} else {
receiverFileTransfer.requestFile(meta.fileId);
}
}
//更新房间状态
useEffect(() => {
const Peer = activeTab === 'send' ? sender : receiver;
const peerCount = activeTab === 'send' ? sharePeerCount : retrievePeerCount;
let status = '';
if (Peer && messages) {
if (!Peer.isInRoom) {
status = activeTab === 'retrieve'
? messages.text.ClipboardApp.roomStatus.receiverEmptyMsg
: messages.text.ClipboardApp.roomStatus.senderEmptyMsg;
} else if (peerCount === 0) {
status = messages.text.ClipboardApp.roomStatus.onlyOneMsg;
} else {
if (activeTab === 'send'){
status = format_peopleMsg(messages.text.ClipboardApp.roomStatus.peopleMsg_template,peerCount+1);
}
else{
status = messages.text.ClipboardApp.roomStatus.connected_dis;
}
}
}
if (activeTab === 'send') {
setShareRoomStatus(status);
} else {
setRetrieveRoomStatus(status);
}
}, [activeTab, sharePeerCount, retrievePeerCount, sender?.isInRoom, receiver?.isInRoom, sender, receiver, messages]);
if (messages === null) {
return <div>Loading...</div>;
}
return (
<div className="container mx-auto px-4 py-8 w-full md:max-w-4xl">
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-2 mb-4">
<Button
variant={activeTab === 'send' ? 'default' : 'outline'}
onClick={() => setActiveTab('send')}
className="flex-1"
>
{messages.text.ClipboardApp.html.senderTab}
</Button>
<Button
variant={activeTab === 'retrieve' ? 'default' : 'outline'}
onClick={() => setActiveTab('retrieve')}
className="flex-1"
>
{messages.text.ClipboardApp.html.retrieveTab}
</Button>
</div>
<Card className="border-8 shadow-md">
<CardHeader>
<CardTitle>{activeTab === 'send' ?messages.text.ClipboardApp.html.shareTitle_dis:messages.text.ClipboardApp.html.retrieveTitle_dis}</CardTitle>
</CardHeader>
<CardContent>
{activeTab === 'send' ? (
<>
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-2 mb-4">
{shareRoomStatus &&
<span>{`${messages.text.ClipboardApp.html.RoomStatus_dis} ${shareRoomStatus}`}</span>
}
</div>
<RichTextEditor
value={shareContent}
onChange={(value) => setShareContent(value)}
/>
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-2 mb-4">
<ReadClipboardButton title={messages.text.ClipboardApp.html.Paste_dis} onRead={(text:string) => setShareContent(text)}/>
<WriteClipboardButton title={messages.text.ClipboardApp.html.Copy_dis} textToCopy={richTextToPlainText(shareContent)}/>
</div>
<div className="mb-2">
<FileUploadHandler onFilePicked={onFilePicked} />
<FileListDisplay
mode="sender"
files={sendFiles}
fileProgresses={sendProgress}
onDelete={removeSenderFile}
/>
</div>
<div className="flex flex-col md:flex-row items-center space-y-2 md:space-y-0 md:space-x-2 mb-2">
<span>{messages.text.ClipboardApp.html.inputRoomId_tips}</span>
<Input
value={shareRoomId}//展示值
onChange={(e) => handleShareRoomCheck(e.target.value)}
className="w-full md:w-36 border-2 border-gray-300 rounded-md px-3 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 transition-all duration-200"
/>
<Button className="w-full"
onClick={() => handleJoinRoom(true)}
disabled={ sender? sender.isInRoom : false }//如果已经在房间则停用进入房间功能
>{messages.text.ClipboardApp.html.joinRoom_dis}</Button>
</div>
<div className="flex space-x-2 mb-2">
<AnimatedButton
className="w-full"
onClick={handleShare}
loadingText={messages.text.ClipboardApp.html.startSending_loadingText}
>
{messages.text.ClipboardApp.html.startSending_dis}
</AnimatedButton>
</div>
{shareMessage && <p className="mb-4">{shareMessage}</p>}
</>
):(
<>
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-2 mb-4">
{retrieveRoomStatus &&
<span>{`${messages.text.ClipboardApp.html.RoomStatus_dis} ${retrieveRoomStatus}`}</span>
}
</div>
<div className="mb-4">
<ReadClipboardButton title={messages.text.ClipboardApp.html.readClipboard_dis} onRead={(text:string) => setRetrieveRoomId(text)}/>
</div>
<div className="mb-4">
<Input
value={retrieveRoomId}
onChange={(e) => setRetrieveRoomId(e.target.value)}
placeholder={messages.text.ClipboardApp.html.retrieveRoomId_placeholder}
className="w-full md:w-36 border-2 border-gray-300 rounded-md px-3 py-2 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 transition-all duration-200"
/>
</div>
<div className="mb-4">
<Button className="w-full"
onClick={() => handleJoinRoom(false)}
ref={retrieveJoinRoomBtnRef}
disabled={ receiver? receiver.isInRoom : false }//如果已经在房间则停用进入房间功能
>{messages.text.ClipboardApp.html.joinRoom_dis}</Button>
</div>
{retrievedContent && (
<>
<RichTextEditor value={retrievedContent} onChange={ (value) => setRetrievedContent(value)}/>
<div className="flex flex-col md:flex-row space-y-2 md:space-y-0 md:space-x-2 mb-2">
<WriteClipboardButton title={messages.text.ClipboardApp.html.Copy_dis} textToCopy={richTextToPlainText(retrievedContent)}/>
</div>
</>
)}
<FileListDisplay
mode="receiver"
files={retrievedFileMetas}
fileProgresses={receiveProgress}
onDownload={handleDownload}
onRequest={handleRequest}
onLocationPick={onLocationPick}
saveType={receiverFileTransfer?.saveType}
/>
{retrieveMessage && <p className="mb-4">{retrieveMessage}</p>}
</>
)}
</CardContent>
</Card>
{activeTab === 'send' && shareLink !== '' &&(
<Card className="border-2 shadow-md">
<CardHeader>
<CardTitle>{messages.text.ClipboardApp.html.RetrieveMethodTitle}</CardTitle>
</CardHeader>
<CardContent>
<QRCodeComponent RoomID={shareRoomId} shareLink={shareLink} />
</CardContent>
</Card>
)
}
</div>
);
};
export default AdvancedClipboardApp;
+3
View File
@@ -0,0 +1,3 @@
export function Divider() {
return <div className="w-px h-6 bg-gray-300" />;
}
@@ -0,0 +1,34 @@
import { AlignLeft, AlignCenter, AlignRight } from 'lucide-react';
import { AlignmentType } from '../types';
interface AlignmentToolsProps {
alignText: (alignment: AlignmentType) => void;
}
export function AlignmentTools({ alignText }: AlignmentToolsProps) {
return (
<div className="flex flex-wrap gap-1">
<button
className="p-1.5 hover:bg-gray-200 rounded"
onClick={() => alignText('left')}
title="Align left"
>
<AlignLeft className="w-3.5 h-3.5" />
</button>
<button
className="p-1.5 hover:bg-gray-200 rounded"
onClick={() => alignText('center')}
title="Align center"
>
<AlignCenter className="w-3.5 h-3.5" />
</button>
<button
className="p-1.5 hover:bg-gray-200 rounded"
onClick={() => alignText('right')}
title="Align right"
>
<AlignRight className="w-3.5 h-3.5" />
</button>
</div>
);
}
@@ -0,0 +1,35 @@
import { Bold, Italic, Underline } from 'lucide-react';
import { FormatType } from '../types';
interface BasicFormatToolsProps {
isStyleActive: (style: string) => boolean;
formatText: (style: FormatType) => void;
}
export function BasicFormatTools({ isStyleActive, formatText }: BasicFormatToolsProps) {
return (
<div className="flex flex-wrap gap-1">
<button
className={`p-1.5 rounded ${isStyleActive('bold') ? 'bg-gray-200' : 'hover:bg-gray-200'}`}
onClick={() => formatText('bold')}
title="Bold"
>
<Bold className="w-3.5 h-3.5" />
</button>
<button
className={`p-1.5 rounded ${isStyleActive('italic') ? 'bg-gray-200' : 'hover:bg-gray-200'}`}
onClick={() => formatText('italic')}
title="Italic"
>
<Italic className="w-3.5 h-3.5" />
</button>
<button
className={`p-1.5 rounded ${isStyleActive('underline') ? 'bg-gray-200' : 'hover:bg-gray-200'}`}
onClick={() => formatText('underline')}
title="Underline"
>
<Underline className="w-3.5 h-3.5" />
</button>
</div>
);
}
@@ -0,0 +1,38 @@
import { Type, Palette } from 'lucide-react';
import { SelectMenu } from '../components/SelectMenu';
import { StyleOption,FontStyleType } from '../types';
interface FontToolsProps {
fontFamilies: StyleOption[];
fontSizes: StyleOption[];
colors: StyleOption[];
setFontStyle: (property: FontStyleType, value: string) => void;
}
export function FontTools({ fontFamilies, fontSizes, colors, setFontStyle }: FontToolsProps) {
return (
<div className="flex flex-wrap gap-1">
<SelectMenu
options={fontFamilies}
onChange={(value) => setFontStyle('family', value)}
icon={Type}
placeholder="Font"
className="text-sm"
/>
<SelectMenu
options={fontSizes}
onChange={(value) => setFontStyle('size', value)}
icon={Type}
placeholder="Size"
className="text-sm w-16"
/>
<SelectMenu
options={colors}
onChange={(value) => setFontStyle('color', value)}
icon={Palette}
placeholder="Color"
className="text-sm w-20"
/>
</div>
);
}
@@ -0,0 +1,35 @@
import { Link2, Image, Code } from 'lucide-react';
interface InsertToolsProps {
insertLink: () => void;
insertImage: () => void;
insertCodeBlock: () => void;
}
export function InsertTools({ insertLink, insertImage, insertCodeBlock }: InsertToolsProps) {
return (
<div className="flex flex-wrap gap-1">
<button
className="p-1.5 hover:bg-gray-200 rounded"
onClick={insertLink}
title="Insert url"
>
<Link2 className="w-3.5 h-3.5" />
</button>
<button
className="p-1.5 hover:bg-gray-200 rounded"
onClick={insertImage}
title="Upload image"
>
<Image className="w-3.5 h-3.5" />
</button>
<button
className="p-1.5 hover:bg-gray-200 rounded"
onClick={insertCodeBlock}
title="Insert code"
>
<Code className="w-3.5 h-3.5" />
</button>
</div>
);
}
@@ -0,0 +1,162 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { EditorProps, CustomClipboardEvent, DOMNodeWithStyle } from './types';
import { fontFamilies, fontSizes, colors } from './constants';
import { useEditorCommands } from './hooks/useEditorCommands';
import { useSelection } from './hooks/useSelection';
import { useStyleManagement } from './hooks/useStyleManagement';
import { BasicFormatTools } from './EditorToolbar/BasicFormatTools';
import { FontTools } from './EditorToolbar/FontTools';
import { AlignmentTools } from './EditorToolbar/AlignmentTools';
import { InsertTools } from './EditorToolbar/InsertTools';
import { Divider } from './Divider';
const RichTextEditor: React.FC<EditorProps> = ({ onChange, value = '' }) => {
const editorRef = useRef<HTMLDivElement>(null);
const [html, setHtml] = useState(value);
const [isMounted, setIsMounted] = useState(false);
const isInternalChange = useRef(false);
useEffect(() => {
setIsMounted(true);
}, []);
//在挂载后更新编辑区内容,监听外部 value 变化
useEffect(() => {
if (isMounted && editorRef.current && !isInternalChange.current) {
// 只有当内容真正不同时才更新
if (editorRef.current.innerHTML !== value) {
editorRef.current.innerHTML = value;
setHtml(value);
}
}
isInternalChange.current = false;
}, [value, isMounted]);
// 处理内容变化
const handleChange = useCallback(() => {
if (editorRef.current) {
const content = (editorRef.current as HTMLDivElement).innerHTML;
if (content !== html) {// 如果内容没有变化,不触发更新
isInternalChange.current = true;
setHtml(content);
onChange(content);
}
}
}, [html, onChange]);
const {
formatText,
alignText,
setFontStyle,
insertLink,
insertImage,
insertCodeBlock
} = useEditorCommands(editorRef, handleChange);
const getSelection = useSelection();
const { findStyleParent } = useStyleManagement(editorRef);
// 检查当前选中文本的样式
const isStyleActive = useCallback((style: string): boolean => {
if (typeof window === 'undefined') return false;
const selectionInfo = getSelection();
if (!selectionInfo || !selectionInfo.selection.toString()) return false;
const node = selectionInfo.selection.anchorNode;
if (!node) return false;
const styleParent = findStyleParent(node as DOMNodeWithStyle, style);
return !!styleParent;
}, [findStyleParent, getSelection]);
const handlePaste = useCallback((e: CustomClipboardEvent) => {
// 处理图片粘贴
if (Array.from(e.clipboardData.items).some(item => item.type.indexOf('image') !== -1)) {
const items = Array.from(e.clipboardData.items);
const imageItem = items.find(item => item.type.indexOf('image') !== -1);
if (imageItem) {
e.preventDefault();
const blob = imageItem.getAsFile();
if (!blob) return;
const reader = new FileReader();
reader.onload = (event: ProgressEvent<FileReader>) => {
if (!event.target || !event.target.result) return;
const img = document.createElement('img');
img.src = event.target.result as string;
img.style.maxWidth = '100%';
img.style.height = 'auto';
img.style.margin = '10px 0';
const selectionInfo = getSelection();
if (!selectionInfo) return;
const { range } = selectionInfo;
range.deleteContents();
range.insertNode(img);
handleChange();
};
reader.readAsDataURL(blob);
}
return;
}
// 处理普通文本
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
if (typeof document !== 'undefined') {
document.execCommand('insertText', false, text);
}
}, [getSelection, handleChange]);
if (!isMounted) {
return <div>Loading...</div>;
}
return (
<div className="w-full space-x-2 mb-4">
<div className="border rounded-lg shadow-sm overflow-hidden">
{/* 工具栏 - 添加浅灰色背景和底部边框 */}
<div className="flex flex-wrap gap-1 p-2 bg-gray-50 border-b">
{/* 基础格式工具组 */}
<BasicFormatTools
isStyleActive={isStyleActive}
formatText={formatText}
/>
<Divider />
{/* 字体相关选择器组 */}
<FontTools
fontFamilies={fontFamilies}
fontSizes={fontSizes}
colors={colors}
setFontStyle={setFontStyle}
/>
<Divider />
{/* 对齐工具组 */}
<AlignmentTools alignText={alignText} />
<Divider />
{/* 插入工具组 */}
<InsertTools
insertLink={insertLink}
insertImage={insertImage}
insertCodeBlock={insertCodeBlock}
/>
</div>
{/* 编辑区域 - 添加纯白背景和内部阴影效果 */}
<div
ref={editorRef}
className="p-4 min-h-[200px] md:min-h-[400px] focus:outline-none bg-white shadow-inner"
contentEditable
onPaste={handlePaste}
onInput={handleChange}
suppressContentEditableWarning
/>
</div>
</div>
);
};
export default RichTextEditor;
@@ -0,0 +1,27 @@
import React from 'react';
import { SelectMenuProps } from '../types';
// 下拉选择组件
export const SelectMenu: React.FC<SelectMenuProps> = ({
options,
onChange,
icon: Icon,
placeholder,
className
}) => (
<div className="relative inline-block">
<select
className={`appearance-none bg-transparent border rounded p-1.5 pr-6 hover:bg-gray-200 focus:outline-none ${className}`}
onChange={(e) => onChange(e.target.value)}
>
<option value="">{placeholder}</option>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<div className="absolute right-1.5 top-1/2 transform -translate-y-1/2 pointer-events-none">
<Icon className="w-3.5 h-3.5" />
</div>
</div>
);
+31
View File
@@ -0,0 +1,31 @@
export const styleMap = {
'bold': 'fontWeight',
'italic': 'fontStyle',
'underline': 'textDecoration'
} as const;
export const fontFamilies = [
{ label: 'Default', value: 'inherit' },
{ label: 'Arial', value: 'Arial' },
{ label: 'Times New Roman', value: 'Times New Roman' },
{ label: 'Courier New', value: 'Courier New' },
{ label: 'Georgia', value: 'Georgia' }
];
export const fontSizes = [
{ label: 'Small', value: '12px' },
{ label: 'Normal', value: '16px' },
{ label: 'Large', value: '20px' },
{ label: 'Extra Large', value: '24px' },
{ label: '28px', value: '28px' },
{ label: '32px', value: '32px' },
{ label: '36px', value: '36px' },
{ label: '40px', value: '40px' }
];
export const colors = [
{ label: 'Black', value: '#000000' },
{ label: 'Red', value: '#FF0000' },
{ label: 'Green', value: '#008000' },
{ label: 'Blue', value: '#0000FF' }
];
@@ -0,0 +1,299 @@
import { useCallback } from 'react';
import { FormatType, AlignmentType, FontStyleType, DOMNodeWithStyle, StyledElement } from '../types';
import { useSelection } from './useSelection';
import { useStyleManagement } from './useStyleManagement';
import { removeStyle } from '../utils/textFormatting';
import { handleImageUpload } from '../utils/imageHandling';
import { styleMap, } from '../constants';
export const useEditorCommands = (
editorRef: React.RefObject<HTMLDivElement>,
handleChange: () => void
) => {
const getSelection = useSelection();
const { findStyleParent, cleanupSpan } = useStyleManagement(editorRef);
// Format text (bold, italic, underline)--格式化文本
const formatText = useCallback((format: FormatType) => {
if (typeof window === 'undefined') return;
const selectionInfo = getSelection();
if (!selectionInfo || !selectionInfo.selection.toString()) return;
const { selection, range } = selectionInfo;
const styleParent = findStyleParent(selection.anchorNode as DOMNodeWithStyle, styleMap[format]);
if (styleParent) {
// 移除样式
removeStyle(styleParent, format);
} else {
// 添加样式
const span = document.createElement('span');
switch (format) {
case 'bold':
span.style.fontWeight = 'bold';
break;
case 'italic':
span.style.fontStyle = 'italic';
break;
case 'underline':
span.style.textDecoration = 'underline';
break;
}
// 如果选中的内容在一个span内,且该span没有目标样式,直接添加样式
const parentElement = selection.anchorNode?.parentElement;
if (parentElement &&
parentElement.tagName === 'SPAN' &&
!(parentElement as StyledElement).style[styleMap[format]] &&
parentElement !== editorRef.current) {
(parentElement as StyledElement).style[styleMap[format]] = span.style[styleMap[format]];
} else {
// 否则创建新的span
span.appendChild(range.extractContents());
range.insertNode(span);
}
}
// 保持选区
const newRange = document.createRange();
selection.removeAllRanges();
selection.addRange(newRange);
// 更新 HTML
handleChange();
}, [findStyleParent, getSelection, removeStyle]);
// Align text--对齐文本
const alignText = useCallback((alignment: AlignmentType) => {
if (!editorRef.current || typeof window === 'undefined') return;
const selectionInfo = getSelection();
if (!selectionInfo) return;
// 找到当前文本节点或其容器
let textNode = selectionInfo.selection.anchorNode as DOMNodeWithStyle;
// 如果是文本节点,获取其父元素
if (textNode.nodeType === 3) {
textNode = textNode.parentElement as DOMNodeWithStyle;
}
// 向外查找最外层的样式容器(例如带有颜色或大小的span)
let outerContainer = textNode;
while (
outerContainer.parentElement &&
outerContainer.parentElement !== editorRef.current &&
(outerContainer.parentElement as HTMLElement).tagName === 'SPAN'
) {
outerContainer = outerContainer.parentElement as DOMNodeWithStyle;
}
// 创建或找到div容器来处理对齐
let alignmentContainer: HTMLElement;
if (
outerContainer.parentElement === editorRef.current ||
(outerContainer.parentElement as HTMLElement).tagName !== 'DIV'
) {
// 需要创建新的对齐容器
alignmentContainer = document.createElement('div');
alignmentContainer.style.textAlign = alignment;
// 包装现有内容
outerContainer.parentElement?.insertBefore(alignmentContainer, outerContainer);
alignmentContainer.appendChild(outerContainer);
} else {
// 使用已存在的对齐容器
alignmentContainer = outerContainer.parentElement as HTMLElement;
alignmentContainer.style.textAlign = alignment;
}
// 更新 HTML
handleChange();
}, [getSelection]);
// Set font style
const setFontStyle = useCallback((type: FontStyleType, value: string) => {
if (typeof window === 'undefined') return;
const selectionInfo = getSelection();
if (!selectionInfo || !selectionInfo.selection.toString()) return;
const { selection, range } = selectionInfo;
// 映射样式类型到实际的 CSS 属性名
const stylePropertyMap = {
'family': 'fontFamily',
'size': 'fontSize',
'color': 'color'
};
const styleProperty = stylePropertyMap[type];
// 获取选中内容的范围
const rangeContent = range.cloneContents();
// 检查选中内容是否包含块级<p> / <div>元素
const containsBlock = Array.from(rangeContent.childNodes).some(
node => node.nodeType === 1 && ['P', 'DIV'].includes((node as HTMLElement).tagName)
);
if (containsBlock) {
// 如果选中内容包含块级元素,遍历处理每个块级元素内的文本
const blocks = Array.from(rangeContent.childNodes).filter(
node => node.nodeType === 1 && ['P', 'DIV'].includes((node as HTMLElement).tagName)
);
blocks.forEach(block => {
const textNodes = [];
const walker = document.createTreeWalker(
block,
NodeFilter.SHOW_TEXT,
null
);
let node;
while (node = walker.nextNode()) {
textNodes.push(node);
}
textNodes.forEach(textNode => {
// 检查父元素是否已经是span
const parent = textNode.parentNode as HTMLElement;
if (parent.tagName === 'SPAN') {
(parent as StyledElement).style[styleProperty] = value;
} else {
const span = document.createElement('span') as StyledElement;
span.style[styleProperty] = value;
parent.insertBefore(span, textNode);
span.appendChild(textNode);
}
});
});
// 清除原有内容并插入新内容
range.deleteContents();
range.insertNode(rangeContent);
} else {
// 如果是普通文本,使用原来的逻辑
let styleParent = findStyleParent(selection.anchorNode as DOMNodeWithStyle, styleProperty);
if (styleParent && !['P', 'DIV'].includes(styleParent.tagName)) {
if (value === 'inherit') {
styleParent.style[styleProperty] = '';
cleanupSpan(styleParent);
} else {
styleParent.style[styleProperty] = value;
}
} else {
// 否则创建新的 span
const span = document.createElement('span') as StyledElement;
span.style[styleProperty] = value;
span.appendChild(range.extractContents());
range.insertNode(span);
}
}
// 保持选区
selection.removeAllRanges();
selection.addRange(range);
handleChange();
}, [getSelection, findStyleParent, cleanupSpan]);
// Insert link
const insertLink = useCallback(() => {
const selection = window.getSelection();
let text = "test";
if (selection && !selection.isCollapsed) {
// 如果有选中文本,则使用选中的文本作为链接文字
text = selection.toString();
}
// 使用一个prompt,用空格分隔链接和文字
const input = prompt('Please enter the link address and text (separated by space):', `https:// ${text}`);
if (input) {
// 分割输入得到url和text
const [url, ...textParts] = input.split(' ');
const text = textParts.join(' '); // 处理文字中可能包含空格的情况
if (url && text) {
const selectionInfo = getSelection();
if (!selectionInfo) return;
const { range } = selectionInfo;
const link = document.createElement('a');
link.href = url;
link.textContent = text;
link.target = '_blank';
link.style.color = '#0066cc';
link.style.textDecoration = 'underline';
range.deleteContents();
range.insertNode(link);
handleChange();
}
}
}, [getSelection]);
// Insert image
const insertImage = useCallback(() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (e: Event) => {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event: ProgressEvent<FileReader>) => {
if (!event.target || !event.target.result) return;
const img = document.createElement('img');
img.src = event.target.result as string;
img.style.maxWidth = '100%';
img.style.height = 'auto';
img.style.margin = '10px 0';
const selectionInfo = getSelection();
if (!selectionInfo) return;
const { range } = selectionInfo;
range.deleteContents();
range.insertNode(img);
handleChange();
};
reader.readAsDataURL(file);
}
};
input.click();
}, [getSelection]);
// Insert code block
const insertCodeBlock = useCallback(() => {
const code = prompt('insert code:');
if (!code) return;
const selectionInfo = getSelection();
if (!selectionInfo) return;
const { range } = selectionInfo;
const pre = document.createElement('pre');
const codeElement = document.createElement('code');
pre.style.backgroundColor = '#f6f8fa';
pre.style.padding = '16px';
pre.style.borderRadius = '6px';
pre.style.overflow = 'auto';
pre.style.margin = '10px 0';
codeElement.style.fontFamily = 'monospace';
codeElement.style.whiteSpace = 'pre';
codeElement.textContent = code;
pre.appendChild(codeElement);
range.deleteContents();
range.insertNode(pre);
handleChange();
}, [getSelection]);
return {
formatText,
alignText,
setFontStyle,
insertLink,
insertImage,
insertCodeBlock
};
};
@@ -0,0 +1,13 @@
import { useCallback } from 'react';
import { SelectionInfo } from '../types';
export const useSelection = () => {
// 获取选区
return useCallback((): SelectionInfo | null => {
if (typeof window === 'undefined') return null;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
return { selection, range };
}, []);
};
@@ -0,0 +1,49 @@
import { useCallback } from 'react';
import { DOMNodeWithStyle, StyledElement } from '../types';
export const useStyleManagement = (editorRef: React.RefObject<HTMLDivElement>) => {
// 查找拥有指定样式的最近父元素
const findStyleParent = useCallback((node: DOMNodeWithStyle, styleType: string): StyledElement | null => {
if (typeof window === 'undefined') return null;
let current = node;
// 如果当前节点是文本节点,从其父节点开始查找
if (current.nodeType === 3) {
current = current.parentElement as DOMNodeWithStyle;
}
while (current && current !== editorRef.current) {
if (current.nodeType === 1) {
const element = current as HTMLElement;
const style = element.style as any;
if (style[styleType]) {
return current as StyledElement;
}
}
current = current.parentElement as DOMNodeWithStyle;
}
return null;
}, [editorRef]);
// 清理空的或只有继承值的 span 标签
const cleanupSpan = useCallback((span: StyledElement | null) => {
// 首先检查 span 是否存在
if (!span) return;
// 然后检查 editorRef.current 是否存在,并进行比较
// 修改比较逻辑,使用 HTMLElement 作为共同基类进行比较
if (editorRef.current && span.contains(editorRef.current)) return;
// 检查是否只有 inherit 值或没有样式
const hasOnlyInherit = Array.from(span.style).every(
style => !span.style[style] || span.style[style] === 'inherit'
);
if (hasOnlyInherit || !span.style.length) {
const parent = span.parentNode as HTMLElement;
while (span.firstChild) {
parent.insertBefore(span.firstChild, span);
}
parent.removeChild(span);
}
}, [editorRef]);
return { findStyleParent, cleanupSpan };
};
+59
View File
@@ -0,0 +1,59 @@
// 选项类型定义
export interface StyleOption {
label: string;
value: string;
}
// 选择菜单组件的 props 类型
export interface SelectMenuProps {
options: StyleOption[];
onChange: (value: string) => void;
icon: React.ElementType;
placeholder: string;
className: string;
}
// 编辑器内部使用的类型
export interface SelectionInfo {
selection: Selection;
range: Range;
}
// 样式格式类型
export type FormatType = 'bold' | 'italic' | 'underline';
// 对齐方式类型
export type AlignmentType = 'left' | 'center' | 'right';
// 字体样式类型
export type FontStyleType = 'family' | 'size' | 'color';
// 粘贴事件处理函数类型
export interface CustomClipboardEvent extends React.ClipboardEvent<HTMLDivElement> {
clipboardData: DataTransfer;
}
// 扩展 HTMLElement 以支持我们需要的样式属性
export interface StyledElement extends HTMLElement {
style: CSSStyleDeclaration & {
[key: string]: string;
};
tagName: string;
getAttribute(name: string): string | null;
parentNode: HTMLElement;
firstChild: ChildNode | null;
}
// 修改DOM节点类型定义
export interface DOMNodeWithStyle extends Node {
nodeType: number;
parentElement: HTMLElement & {
style: CSSStyleDeclaration;
};
style?: CSSStyleDeclaration;
}
export interface EditorProps {
onChange: (html: string) => void;
value?: string;
}
@@ -0,0 +1,16 @@
export const handleImageUpload = (
file: File,
onSuccess: (imgElement: HTMLImageElement) => void
) => {
const reader = new FileReader();
reader.onload = (event: ProgressEvent<FileReader>) => {
if (!event.target || !event.target.result) return;
const img = document.createElement('img');
img.src = event.target.result as string;
img.style.maxWidth = '100%';
img.style.height = 'auto';
img.style.margin = '10px 0';
onSuccess(img);
};
reader.readAsDataURL(file);
};
@@ -0,0 +1,14 @@
import { FormatType, StyledElement } from '../types';
import { styleMap } from '../constants';
// 移除样式
export const removeStyle = (element: StyledElement, style: FormatType) => {
element.style[styleMap[style]] = '';// 移除指定样式
// 如果span没有其他样式,则移除span标签
if (element.tagName === 'SPAN' && !element.getAttribute('style')) {
const parent = element.parentNode;
while (element.firstChild) {
parent.insertBefore(element.firstChild, element);
}
parent.removeChild(element);
}
};
+46
View File
@@ -0,0 +1,46 @@
import React from 'react';
import { Globe } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { usePathname, useRouter } from 'next/navigation';
import { i18n, Locale,languageDisplayNames } from '@/constants/i18n-config';
const LanguageSwitcher = () => {
const pathname = usePathname();
const router = useRouter();
const switchLanguage = (locale: Locale) => {
const segments = pathname.split('/');
segments[1] = locale;
router.push(segments.join('/'));
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 px-0">
<Globe className="h-4 w-4" />
<span className="sr-only">Switch language</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{i18n.locales.map((locale) => (
<DropdownMenuItem
key={locale}
onClick={() => switchLanguage(locale)}
className="cursor-pointer"
>
{languageDisplayNames[locale]}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
};
export default LanguageSwitcher;
+59
View File
@@ -0,0 +1,59 @@
//一个自定义封装,便于简单使用
//简单用法,使用便捷组件
// import { Tooltip } from '@/components/Tooltip';
// <Tooltip content="这是提示内容">
// <button>悬停查看</button>
// </Tooltip>
// // 需要更多自定义时,使用基础组件
// import {
// Tooltip,
// TooltipContent,
// TooltipProvider,
// TooltipTrigger,
// } from '@/components/ui/tooltip';
// <TooltipProvider>
// <Tooltip>
// <TooltipTrigger>悬停查看</TooltipTrigger>
// <TooltipContent>
// 这是提示内容
// </TooltipContent>
// </Tooltip>
// </TooltipProvider>
import React from 'react';
import {
Tooltip as TooltipRoot,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
type TooltipProps = {
children: React.ReactNode;
content: React.ReactNode;
delayDuration?: number;
};
export const Tooltip: React.FC<TooltipProps> = ({
children,
content,
delayDuration = 200
}) => {
return (
<TooltipProvider>
<TooltipRoot delayDuration={delayDuration}>
<TooltipTrigger asChild>
{children}
</TooltipTrigger>
<TooltipContent className="whitespace-pre-line bg-primary text-primary-foreground text-xs">
{content}
</TooltipContent>
</TooltipRoot>
</TooltipProvider>
);
};
export default Tooltip;
@@ -0,0 +1,69 @@
import Link from 'next/link'
import Image from 'next/image'
import { type BlogPost } from '@/lib/blog'
interface ArticleListItemProps {
post: BlogPost
}
export function ArticleListItem({ post }: ArticleListItemProps) {
return (
<article className="bg-white rounded-xl shadow-lg hover:shadow-xl transition-shadow overflow-hidden">
{/* 增大封面图高度 */}
<div className="relative h-80 w-full">
<Image
src={post.frontmatter.cover}
alt={post.frontmatter.title}
fill
className="object-cover transition-transform duration-300 hover:scale-105"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
priority
/>
</div>
<div className="p-8"> {/* 增大内边距 */}
<div className="flex items-center gap-4 text-sm text-gray-500 mb-4">
<time className="font-medium">
{new Date(post.frontmatter.date).toLocaleDateString()}
</time>
<span>·</span>
<div className="flex gap-2 flex-wrap">
{post.frontmatter.tags.map((tag) => (
<span
key={tag}
className="bg-gray-100 px-3 py-1 rounded-full hover:bg-gray-200 transition-colors"
>
{tag}
</span>
))}
</div>
</div>
<Link href={`/blog/${post.slug}`}>
<h2 className="text-3xl font-bold mb-4 hover:text-blue-600 transition-colors leading-tight">
{post.frontmatter.title}
</h2>
</Link>
<p className="text-gray-600 mb-6 text-lg leading-relaxed line-clamp-3">
{post.frontmatter.description}
</p>
<div className="flex items-center justify-between pt-4 border-t border-gray-100">
<Link
href={`/blog/${post.slug}`}
className="text-blue-600 hover:text-blue-800 font-medium inline-flex items-center text-lg"
>
Read more
<svg className="w-5 h-5 ml-2" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
<div className="flex items-center gap-3">
<span className="text-sm">by <span className="font-bold">{post.frontmatter.author}</span></span>
</div>
</div>
</div>
</article>
)
}
+131
View File
@@ -0,0 +1,131 @@
import Image from 'next/image'
import { ComponentProps, DetailedHTMLProps, HTMLAttributes } from 'react'
import dynamic from 'next/dynamic'
// 动态导入 Mermaid 组件
const Mermaid = dynamic(() => import('@/components/blog/Mermaid'), { ssr: false });
export type MDXComponents = {
p: (props: DetailedHTMLProps<HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>) => JSX.Element
img: (props: ComponentProps<'img'>) => JSX.Element
pre: (props: DetailedHTMLProps<HTMLAttributes<HTMLPreElement>, HTMLPreElement>) => JSX.Element
code: (props: DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>) => JSX.Element
table: (props: DetailedHTMLProps<HTMLAttributes<HTMLTableElement>, HTMLTableElement>) => JSX.Element
thead: (props: DetailedHTMLProps<HTMLAttributes<HTMLTableSectionElement>, HTMLTableSectionElement>) => JSX.Element
tbody: (props: DetailedHTMLProps<HTMLAttributes<HTMLTableSectionElement>, HTMLTableSectionElement>) => JSX.Element
tr: (props: DetailedHTMLProps<HTMLAttributes<HTMLTableRowElement>, HTMLTableRowElement>) => JSX.Element
th: (props: DetailedHTMLProps<HTMLAttributes<HTMLTableCellElement>, HTMLTableCellElement>) => JSX.Element
td: (props: DetailedHTMLProps<HTMLAttributes<HTMLTableCellElement>, HTMLTableCellElement>) => JSX.Element
blockquote: (props: DetailedHTMLProps<HTMLAttributes<HTMLQuoteElement>, HTMLQuoteElement>) => JSX.Element
ul: (props: DetailedHTMLProps<HTMLAttributes<HTMLUListElement>, HTMLUListElement>) => JSX.Element
ol: (props: DetailedHTMLProps<HTMLAttributes<HTMLOListElement>, HTMLOListElement>) => JSX.Element
li: (props: DetailedHTMLProps<HTMLAttributes<HTMLLIElement>, HTMLLIElement>) => JSX.Element
mermaid: React.ComponentType<{ children: string }>
}
// Custom MDX components
export const mdxComponents: MDXComponents = {
p: ({ children, ...props }) => (
<div className="mb-6 leading-relaxed text-gray-700" {...props}>{children}</div>
),
img: (props) => {
const { src, ...rest } = props;
if (!src) {
return <div className="my-8">Image source is missing</div>;
}
return (
<div className="my-8">
<Image
src={src}
{...rest}
width={800}
height={400}
className="rounded-lg w-full"
alt={props.alt || ''}
/>
{props.alt && (
<div className="text-center text-sm text-gray-600 mt-2 italic">
{props.alt}
</div>
)}
</div>
);
},
pre: ({ children, ...props }) => (
<pre className="relative my-6 rounded-lg bg-gray-50 border border-gray-200 p-4 overflow-x-auto" {...props}>
{children}
</pre>
),
code: ({ children, className, ...props }) => {
const isInlineCode = !className;
return isInlineCode ? (
<code className="bg-gray-50 rounded px-1.5 py-0.5 text-gray-800 border border-gray-200 text-sm" {...props}>
{children}
</code>
) : (
<code className="block text-gray-800 text-sm" {...props}>
{children}
</code>
);
},
table: ({ children, ...props }) => (
<div className="my-8 w-full overflow-x-auto">
<table className="min-w-full divide-y divide-gray-300 border border-gray-300" {...props}>
{children}
</table>
</div>
),
thead: ({ children, ...props }) => (
<thead className="bg-gray-50" {...props}>
{children}
</thead>
),
tbody: ({ children, ...props }) => (
<tbody className="divide-y divide-gray-200 bg-white" {...props}>
{children}
</tbody>
),
tr: ({ children, ...props }) => (
<tr className="hover:bg-gray-50" {...props}>
{children}
</tr>
),
th: ({ children, ...props }) => (
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-r last:border-r-0"
{...props}
>
{children}
</th>
),
td: ({ children, ...props }) => (
<td
className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 border-r last:border-r-0"
{...props}
>
{children}
</td>
),
blockquote: ({ children, ...props }) => (
<blockquote className="border-l-4 border-blue-500 pl-4 my-4 italic text-gray-600 bg-gray-50 py-2 rounded-r-lg" {...props}>
{children}
</blockquote>
),
ul: ({ children, ...props }) => (
<ul className="list-disc list-outside ml-6 my-6 space-y-2 text-gray-700" {...props}>
{children}
</ul>
),
ol: ({ children, ...props }) => (
<ol className="list-decimal list-outside ml-6 my-6 space-y-2 text-gray-700" {...props}>
{children}
</ol>
),
li: ({ children, ...props }) => (
<li className="pl-2 leading-relaxed" {...props}>
{children}
</li>
),
mermaid: Mermaid, // 使用定义的 Mermaid 组件
}
+21
View File
@@ -0,0 +1,21 @@
'use client' // 标记为客户端组件
import mermaid from 'mermaid'
import { useEffect, useRef } from 'react'
// 初始化 Mermaid.js
mermaid.initialize({ startOnLoad: false })
const Mermaid: React.FC<{ children: string }> = ({ children }) => {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (ref.current) {
mermaid.init(undefined, ref.current)
}
}, [children])
return <div ref={ref} className="mermaid">{children}</div>
}
export default Mermaid
@@ -0,0 +1,135 @@
"use client";
import React, { useEffect, useState } from 'react';
import clsx from 'clsx';
interface TocItem {
id: string;
text: string;
level: number;
}
interface TableOfContentsProps {
content: string;
}
export const TableOfContents: React.FC<TableOfContentsProps> = ({ content }) => {
const [activeId, setActiveId] = useState<string>('');
const [toc, setToc] = useState<TocItem[]>([]);
// 生成合法的 ID,保留中文字符
const generateValidId = (text: string): string => {
return encodeURIComponent(text
.trim() // 移除首尾空格
.replace(/\s+/g, '-') // 将空格替换为连字符
.replace(/\-\-+/g, '-') // 将多个连字符替换为单个
.replace(/^-+/, '') // 移除开头的连字符
.replace(/-+$/, '') // 移除结尾的连字符
);
};
useEffect(() => {
// 解析内容生成目录
const headingRegex = /^(#{1,3})\s+(.+)$/gm;
const items: TocItem[] = [];
let match;
const usedIds = new Set<string>(); // 用于跟踪已使用的ID
while ((match = headingRegex.exec(content)) !== null) {
const level = match[1].length;
const text = match[2].trim();
let id = generateValidId(text);
// 如果ID已存在,添加数字后缀
let counter = 1;
let uniqueId = id;
while (usedIds.has(uniqueId)) {
uniqueId = `${id}-${counter}`;
counter++;
}
usedIds.add(uniqueId);
items.push({ id: uniqueId, text, level });
}
setToc(items);
}, [content]);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
},
{ rootMargin: '-80px 0px -40% 0px' }
);
// 确保所有标题都已经渲染
const setupObserver = () => {
const headers = document.querySelectorAll('h1[id], h2[id], h3[id]');
headers.forEach((header) => observer.observe(header));
};
// 确保 DOM 已更新
if (toc.length > 0) {
// 给 DOM 一点时间来更新
setTimeout(setupObserver, 100);
}
return () => observer.disconnect();
}, [toc]); // 依赖于 toc 而不是 content
const scrollToHeader = (id: string) => {
// 不需要解码 ID,因为它已经是正确的格式
const element = document.getElementById(id);
if (element) {
// 获取元素位置
const rect = element.getBoundingClientRect();
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
// 计算目标位置(考虑固定导航栏的高度,假设是 80px)
const offsetTop = rect.top + scrollTop - 80;
window.scrollTo({
top: offsetTop,
behavior: 'smooth'
});
// 设置当前活动项
setActiveId(id);
}
};
if (toc.length === 0) return null;
return (
<nav className="hidden lg:block sticky top-8 p-6 bg-gray-50 rounded-lg max-h-[calc(100vh-4rem)] overflow-y-auto">
<h4 className="text-lg font-semibold mb-4">Table of contents</h4>
<ul className="space-y-2">
{toc.map((item) => (
<li
key={item.id}
className={clsx(
'transition-all',
item.level === 1 ? 'ml-0' : item.level === 2 ? 'ml-4' : 'ml-8'
)}
>
<button
onClick={() => scrollToHeader(item.id)}
className={clsx(
'block w-full text-left py-1 text-sm hover:text-blue-600 transition-colors',
activeId === item.id
? 'text-blue-600 font-medium'
: 'text-gray-600'
)}
>
{item.text}
</button>
</li>
))}
</ul>
</nav>
);
};
@@ -0,0 +1,65 @@
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface AnimatedButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
onClick?: () => Promise<void> | void;
loadingText?: string;
icon?: React.ReactNode;
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
}
const AnimatedButton = React.forwardRef<HTMLButtonElement, AnimatedButtonProps>(
({
children,
onClick,
className,
loadingText,
icon,
variant = 'default',
disabled,
...props
}, ref) => {
const [isAnimating, setIsAnimating] = useState(false);
const handleClick = async () => {
if (onClick) {
setIsAnimating(true);
try {
await onClick();
} finally {
setTimeout(() => setIsAnimating(false), 500);
}
}
};
return (
<Button
ref={ref}
variant={variant}
className={cn(
'transition-transform duration-200',
isAnimating ? 'scale-95' : '',
className
)}
onClick={handleClick}
disabled={disabled || isAnimating}
{...props}
>
{icon && <span className="mr-2">{icon}</span>}
{isAnimating ? loadingText : children}
</Button>
);
}
);
AnimatedButton.displayName = 'AnimatedButton';
export default AnimatedButton;
// 使用示例
{/* <AnimatedButton
onClick={handleShare}
loadingText="Sending..."
>
Start sending
</AnimatedButton> */}
@@ -0,0 +1,55 @@
//弹窗会在满足条件时自动弹出,并确保只弹出一次
'use client';
import { useEffect, useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
interface AutoPopupDialogProps {
// 用于localStorage的唯一标识
storageKey: string;
// 弹窗标题
title: string;
// 弹窗描述内容
description: string;
// 触发弹窗的条件函数
condition?: () => boolean;
}
export function AutoPopupDialog({
storageKey,
title,
description,
condition = () => true,
}: AutoPopupDialogProps) {
const [open, setOpen] = useState(false);
useEffect(() => {
// 检查是否已经显示过
const hasShown = localStorage.getItem(storageKey);//localStorage 是一种 Web Storage 技术,允许浏览器在客户端本地存储数据。它可以存储键值对(key-value),并且这些数据在页面刷新、浏览器重启后仍然存在,直到手动删除或通过代码清除。
if (!hasShown && condition()) {
setOpen(true);
// 标记为已显示
localStorage.setItem(storageKey, 'true');
}
}, [storageKey, condition]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="text-xl font-semibold">{title}</DialogTitle>
<DialogDescription className="mt-2 text-muted-foreground">
{description}
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,345 @@
import React, { useState, useEffect, useRef } from 'react';
import { Button } from "@/components/ui/button";
import { Download, Trash2 } from "lucide-react";
import { Tooltip } from '@/components/Tooltip';
import TransferProgress from "./TransferProgress"
import { formatFileSize,generateFileId } from '@/lib/myUtils';
import { AutoPopupDialog } from '@/components/self_define/AutoPopupDialog';
import {FileMeta,CustomFile,Progress } from '@/lib/types/file';
import FileTransferButton from "./FileTransferButton"
import { getDictionary } from '@/lib/dictionary';
import { useLocale } from '@/hooks/useLocale';
import type { Messages } from '@/types/messages';
import {formatFolderTips,formatFolderDis } from '@/utils/formatMessage';
interface FileListDisplayProps {
mode: 'sender' | 'receiver';
files: FileMeta[] | CustomFile[];
fileProgresses: { [fileId: string]: {
[peerId: string]: Progress} };
onDownload?: (item: FileMeta) => void;
onRequest?: (item: FileMeta) => void;//请求文件
onDelete?: (item: FileMeta) => void;
onLocationPick?: () => Promise<boolean>;
saveType?: { [fileId: string]: boolean };//文件是存储在磁盘还是内存
largeFileThreshold?: number;
}
// 添加类型判断辅助函数
function isCustomFile(file: FileMeta | CustomFile): file is CustomFile {
return 'lastModified' in file; // 使用 File 对象特有的属性来判断
}
function isFileMetaArray(files: FileMeta[] | CustomFile[]): files is FileMeta[] {
return files.length === 0 || !isCustomFile(files[0]);
}
const FileListDisplay: React.FC<FileListDisplayProps> = ({
mode,
files,
fileProgresses,
onDownload,
onRequest,
onDelete,
onLocationPick,
saveType,
largeFileThreshold = 500 * 1024 * 1024, // 500MB default
}) => {
const locale = useLocale();
const [messages, setMessages] = useState<Messages | null>(null);
const [showFinished, setShowFinished] = useState<{ [fileId: string]: boolean }>({});
// 添加 ref 来存储上一次的 showFinished 状态
const prevShowFinishedRef = useRef<{ [fileId: string]: boolean }>({});
const [pickedLocation, setPickedLocation] = useState<boolean>(false);//是否已经选取过保存目录
const [needPickLocation, setNeedPickLocation] = useState<boolean>(false);//是否需要选择保存目录--大文件或文件夹或用户主动选择
const [folders, setFolders] = useState<FileMeta[]>([]);//将文件中属于 文件夹的 提取出来
const [singleFiles, setSingleFiles] = useState<FileMeta[]>([]);//保留单文件,不属于文件夹
// 添加当前显示的接收端追踪
const [activeTransfers, setActiveTransfers] = useState<{ [fileId: string]: string }>({});
//对是否有文件传输状态进行跟踪
const [isAnyFileTransferring, setIsAnyFileTransferring] = useState(false);
// 添加下载次数的状态
const [downloadCounts, setDownloadCounts] = useState<{ [fileId: string]: number }>({});
useEffect(() => {
getDictionary(locale)
.then(dict => setMessages(dict))
.catch(error => console.error('Failed to load messages:', error));
}, [locale]);
//监控文件传输状态
useEffect(() => {
const hasActiveTransfer = Object.values(fileProgresses).some(fileProgress =>
Object.values(fileProgress).some(progress =>
progress.progress > 0 && progress.progress < 1
)
);
setIsAnyFileTransferring(hasActiveTransfer);
}, [fileProgresses]);
useEffect(() => {//分离出单文件和文件夹
const tempSingleFiles: FileMeta[] = [];
let folders_: { [folderName: string]: FileMeta } = {};
let needPick = false;
// 如果是 CustomFile[] 类型,先转换为 FileMeta[]
const processedFiles: FileMeta[] = isFileMetaArray(files)
? files
: files.map(file => ({
name: file.name,
size: file.size,
fullName: file.fullName,
folderName: file.folderName,
fileType: file.type,
fileId: generateFileId(file)
}));
console.log('Processed files:', processedFiles);
for (let file of processedFiles) {
if (file.folderName !== ""){
folders_[file.folderName] = folders_[file.folderName] || {//如果对象不存在,则初始化
name: file.folderName,
size: 0,
fullName: file.folderName, // 文件夹的 fullName 就是 folderName
fileType: 'folder',
fileId: file.folderName,
folderName: file.folderName,
fileCount: 0,
fileNamesDis: ''
};
folders_[file.folderName].fileCount = (folders_[file.folderName].fileCount ?? 0) + 1;//如果 fileCount 是 undefined,使用默认值 0
folders_[file.folderName].size += file.size;
folders_[file.folderName].fileNamesDis = folders_[file.folderName].fileNamesDis
? folders_[file.folderName].fileNamesDis + `${file.name} ${formatFileSize(file.size)}\n`
: `${file.name} ${formatFileSize(file.size)}\n`;
needPick = true;
} else {
tempSingleFiles.push(file);
if(file.size >= largeFileThreshold)needPick = true;
}
}
// console.log('Single files before setState:', tempSingleFiles);
// console.log('Folders before setState:', Object.values(folders_));
// 使用函数式更新确保状态正确更新
setSingleFiles(prev => {
// console.log('Previous single files:', prev);
// console.log('New single files:', tempSingleFiles);
return [...tempSingleFiles];
});
setFolders(prev => {
// console.log('Previous folders:', prev);
// console.log('New folders:', Object.values(folders_));
return [...Object.values(folders_)];
});
setNeedPickLocation(needPick);//设置是否需要选择保存目录
}, [files,largeFileThreshold]);
useEffect(() => {//如果一个文件同时被多个接收端请求,会先显示第一个,等结束后再显示第二个
let fileIds = [...singleFiles, ...folders].map(file => file.fileId);
fileIds.forEach((fileId) => {
const fileProgress = fileProgresses[fileId];
if (!fileProgress) return;
// 获取当前文件的所有传输进度
const transfers = Object.entries(fileProgress);
// 如果没有活跃传输,选择第一个开始的传输
let newPeerId= '';
if (!activeTransfers[fileId] && transfers.length > 0) {
newPeerId = transfers[0][0];
setActiveTransfers(prev => ({
...prev,
[fileId]: newPeerId // 设置第一个 peerId
}));
}
// set是异步操作 直接使用 newPeerId 而不是从 activeTransfers 中读取
const activePeerId = newPeerId || activeTransfers[fileId];
// 检查当前活跃传输是否完成
if (activePeerId && fileProgress[activePeerId]?.progress >= 1) {
// 当前传输完成,等待2秒后切换到下一个未完成的传输
if (!showFinished[fileId]) {
setShowFinished(prev => ({ ...prev, [fileId]: true }));
setTimeout(() => {
setShowFinished(prev => {
const updated = { ...prev };
delete updated[fileId];
return updated;
});
delete fileProgress[activePeerId];//需要删掉这个peer的进度,否则下次相同文件被请求进度显示不正常
// 找到下一个未完成的传输
const nextTransfer = transfers.find(([pid, prog]) =>
pid !== activePeerId && prog.progress > 0 && prog.progress < 1
);
setActiveTransfers(prev => {
const updated = { ...prev };
if (nextTransfer) {
updated[fileId] = nextTransfer[0];
} else {
delete updated[fileId];
}
return updated;
});
}, 3000);
}
}
});
}, [files, fileProgresses, showFinished,activeTransfers, folders, singleFiles]);
useEffect(() => {//监控 Finished 从false/null跳变为true这个事件 来触发下载
let files_ = [...singleFiles, ...folders];
files_.forEach((item: FileMeta) => {
const currentShowFinished = showFinished[item.fileId];
const prevShowFinished = prevShowFinishedRef.current[item.fileId];
const isSaveToDisk = saveType ? saveType[item.fileId] : false;
// console.log(`last:${prevShowFinished} --> cur:${currentShowFinished}`);
// 检测 false -> true 的跳变
if (!prevShowFinished && currentShowFinished) {
if (!isSaveToDisk && onDownload) {
onDownload(item);
}
// 增加下载次数
setDownloadCounts(prevCounts => ({
...prevCounts,
[item.fileId]: (prevCounts[item.fileId] || 0) + 1,
}));
}
// 更新上一次的状态
prevShowFinishedRef.current[item.fileId] = currentShowFinished;
});
}, [showFinished, singleFiles, folders, saveType, onDownload]);
//每一项文件 对应的动作--进度、下载、删除
const renderItemActions = (item: FileMeta) => {
const fileProgress = fileProgresses[item.fileId];
const activePeerId = activeTransfers[item.fileId];
const progress = activePeerId ? fileProgress?.[activePeerId] : null;
const showCompletion = showFinished[item.fileId];
const isSaveToDisk = saveType ? saveType[item.fileId]:false;
// 获取下载次数
const downloadCount = downloadCounts[item.fileId] || 0;
if (messages === null) {
return <div>Loading...</div>;
}
return (
<div className="flex items-center">
{progress && progress.progress < 1 ? (//显示进度或已完成
<TransferProgress
message={mode === "sender"?messages.text.FileListDisplay.sending_dis:messages.text.FileListDisplay.receiving_dis}
progress={progress}
/>
) : showCompletion ? (
<span className="mr-2 text-sm text-green-500">{messages.text.FileListDisplay.finish_dis}</span>
) : null
}
{mode === 'receiver' && onRequest && onDownload && (//请求 && 下载
<FileTransferButton
onRequest={() => onRequest(item)}
isCurrentFileTransferring={progress ? (progress.progress > 0 && progress.progress < 1) : false}
isOtherFileTransferring={isAnyFileTransferring && !progress}
isSavedToDisk={saveType ? saveType[item.fileId] : false}
/>
)}
{/* 展示下载次数 */}
{mode === 'sender' && <span className="mr-2 text-sm">{messages.text.FileListDisplay.downloadNum_dis}: {downloadCount}</span>}
{mode === 'sender' && onDelete && (
<Button
onClick={() => {
onDelete(item);
}}
variant="destructive"
size="sm"
disabled={progress?(progress?.progress > 0 && progress.progress < 1):false}
>
<Trash2 className="mr-2 h-4 w-4" /> {messages.text.FileListDisplay.delete_dis}
</Button>
)}
</div>
);
};
//每一项文件 对应的展示--meta信息
const renderItem = (item: FileMeta, isFolder: boolean) => {
const filenameDisplayLen = 30;
const formatSize = formatFileSize(item.size);
const tooltipContent = isFolder
? `${formatFolderTips(messages!.text.FileListDisplay.folder_tips_template, item.name, item.fileCount||0,formatSize)}\n ${item.fileNamesDis}`
: `${item.name} ${formatSize}`;
return (
<div key={item.name} className="flex items-center justify-between mb-1">
<Tooltip content={tooltipContent}>
<span className="mr-2 truncate max-w-sm">
{isFolder ? '📁 ' : ''}
{item.name.length > filenameDisplayLen
? `${item.name.slice(0, filenameDisplayLen-3)}...`
: item.name}
{isFolder ? `${formatFolderDis(messages!.text.FileListDisplay.folder_dis_template,item.fileCount||0,formatSize)}` : ` ${formatSize}`}
</span>
</Tooltip>
{renderItemActions(item)}
</div>
);
};
if (messages === null) {
return <div>Loading...</div>;
}
return (
<>
{(singleFiles.length > 0 || folders.length > 0) && (
<>
{/* 自动弹窗组件,当有大文件和文件夹时 只提醒一次 */}
{mode === 'receiver' && (
<div className="mb-2">
<AutoPopupDialog
storageKey="Choose-location-popup-shown"
title={messages.text.FileListDisplay.PopupDialog_title}
description={messages.text.FileListDisplay.PopupDialog_description}
condition={() => needPickLocation}
/>
{/* 常态化提醒选择保存目录 */}
<div className="flex items-center">
<p className="text-red-500 mb-2">
{messages.text.FileListDisplay.chooseSavePath_tips}
</p>
{onLocationPick && (
<Button
onClick={async () => {
const success = await onLocationPick();
if(success) setPickedLocation(true);
}}
variant="outline"
size="sm"
className="mr-2 text-red-500"
>
{messages.text.FileListDisplay.chooseSavePath_dis}
</Button>
)}
</div>
</div>
)}
<div className="mb-2">
{/* 确保文件列表确实被渲染 */}
<div className="files-list">
{singleFiles.map(file => (
<div key={`single-${file.name}`}>
{renderItem(file, false)}
</div>
))}
{folders.map(folder => (
<div key={`folder-${folder.name}`}>
{renderItem(folder, true)}
</div>
))}
</div>
</div>
</>
)}
</>
);
};
export default FileListDisplay;
@@ -0,0 +1,100 @@
import React, { useState,useEffect} from 'react';
import { Button } from "@/components/ui/button";
import { Download } from "lucide-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { getDictionary } from '@/lib/dictionary';
import { useLocale } from '@/hooks/useLocale';
import type { Messages } from '@/types/messages';
interface FileTransferButtonProps {
onRequest: () => void;
isCurrentFileTransferring: boolean;
isOtherFileTransferring: boolean;
isSavedToDisk: boolean;
}
//针对下载中不同状态的按键进行管理
const FileTransferButton = ({
onRequest,
isCurrentFileTransferring,
isOtherFileTransferring,
isSavedToDisk
}: FileTransferButtonProps) => {
const locale = useLocale();
const [messages, setMessages] = useState<Messages | null>(null);
// 按钮状态判断
const isDisabled = isCurrentFileTransferring || isSavedToDisk || isOtherFileTransferring;
useEffect(() => {
getDictionary(locale)
.then(dict => setMessages(dict))
.catch(error => console.error('Failed to load messages:', error));
}, [locale]);
// 根据不同状态显示不同的提示信息
const getTooltipContent = () => {
if (isSavedToDisk) return messages!.text.FileTransferButton.SavedToDisk_tips;
if (isCurrentFileTransferring) return messages!.text.FileTransferButton.CurrentFileTransferring_tips;
if (isOtherFileTransferring) return messages!.text.FileTransferButton.OtherFileTransferring_tips;
return messages!.text.FileTransferButton.download_tips;
};
// 根据状态设置不同的按钮样式和类名
const getButtonStyles = () => {
if (isSavedToDisk) {
return {
variant: "ghost" as const,
className: "mr-2 text-gray-500"
};
}
if (isCurrentFileTransferring) {
return {
variant: "outline" as const,
className: "mr-2 cursor-not-allowed"
};
}
if (isOtherFileTransferring) {
return {
variant: "outline" as const,
className: "mr-2 cursor-not-allowed bg-gray-100 border-gray-300 text-gray-500"
};
}
return {
variant: "outline" as const,
className: "mr-2 hover:bg-blue-50"
};
};
const buttonStyles = getButtonStyles();
if (messages === null) {
return <div>Loading...</div>;
}
return (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block"> {/* 包装器确保禁用状态下tooltip仍然工作 */}
<Button
onClick={onRequest}
variant={buttonStyles.variant}
size="sm"
className={buttonStyles.className}
disabled={isDisabled}
>
<Download className={`mr-2 h-4 w-4 ${isOtherFileTransferring ? 'opacity-50' : ''}`} />
{isSavedToDisk ? messages.text.FileTransferButton.Saved_dis :
isOtherFileTransferring ? messages.text.FileTransferButton.Waiting_dis :
messages.text.FileTransferButton.Download_dis}
</Button>
</span>
</TooltipTrigger>
<TooltipContent
side="top"
className="bg-gray-800 text-white px-3 py-2 rounded-md text-sm"
>
{getTooltipContent()}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
export default FileTransferButton;
+148
View File
@@ -0,0 +1,148 @@
import React, { useRef, useState, useEffect } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Copy, Download, Check } from 'lucide-react';
import { WriteClipboardButton } from './clipboard_btn';
import { getDictionary } from '@/lib/dictionary';
import { useLocale } from '@/hooks/useLocale';
import type { Messages } from '@/types/messages';
interface QRCodeComponentProps {
RoomID: string;
shareLink: string;
}
const QRCodeComponent: React.FC<QRCodeComponentProps> = ({ RoomID,shareLink }) => {
const locale = useLocale();
const [messages, setMessages] = useState<Messages | null>(null);
const qrRef = useRef<HTMLDivElement>(null);
const [isCopied, setIsCopied] = useState<boolean>(false);
const copyToClipboard = async () => {
if (!qrRef.current) return;
try {
const svgElement = qrRef.current.querySelector('svg');
if (!svgElement) return;
const svgData = new XMLSerializer().serializeToString(svgElement);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
const img = new Image();
img.onload = async () => {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const pngFile = await new Promise<Blob>((resolve) => canvas.toBlob((blob) => resolve(blob!), 'image/png'));
await navigator.clipboard.write([
new ClipboardItem({
'image/png': pngFile
})
]);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
};
img.src = 'data:image/svg+xml;base64,' + btoa(svgData);
} catch (err) {
console.error('Failed to copy QR code: ', err);
alert('Failed to copy QR code. Please try again.');
}
};
useEffect(() => {
getDictionary(locale)
.then(dict => setMessages(dict))
.catch(error => console.error('Failed to load messages:', error));
}, [locale]);
const downloadQRCode = () => {
if (!qrRef.current) return;
const svgElement = qrRef.current.querySelector('svg');
if (!svgElement) return;
const svgData = new XMLSerializer().serializeToString(svgElement);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) return;
const img = new Image();
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const pngFile = canvas.toDataURL('image/png');
const downloadLink = document.createElement('a');
downloadLink.download = 'qrcode.png';
downloadLink.href = pngFile;
downloadLink.click();
};
img.src = 'data:image/svg+xml;base64,' + btoa(svgData);
};
if (messages === null) {
return <div>Loading...</div>;
}
return (
<div className="bg-blue-100 p-4 rounded-md">
<p className="text-blue-700 mb-4">
{messages.text.RetrieveMethod.P}
</p>
{/* 使用 flex-col 替代 list,更好控制移动端布局 */}
<div className="flex flex-col space-y-4">
{/* RoomID 部分 */}
<div className="flex flex-col space-y-2">
<div className="flex flex-wrap items-center gap-2">
<span>{messages.text.RetrieveMethod.RoomId_tips+RoomID}</span>
<WriteClipboardButton title={messages.text.RetrieveMethod.copyRoomId_tips} textToCopy={RoomID} />
</div>
</div>
{/* URL 部分 */}
<div className="flex flex-col space-y-2">
<div className="break-all">
{messages.text.RetrieveMethod.url_tips+shareLink}
</div>
<div className="flex flex-wrap gap-2">
<WriteClipboardButton title={messages.text.RetrieveMethod.copyUrl_tips} textToCopy={shareLink} />
</div>
</div>
{/* QR Code 部分 */}
<div className="flex flex-col space-y-2">
<div>{messages.text.RetrieveMethod.scanQR_tips}</div>
<div className="flex flex-wrap gap-2">
<Button onClick={copyToClipboard} variant="outline" className="w-full sm:w-auto">
{isCopied ? (
<>
<Check className="w-4 h-4 mr-2" />
{messages.text.RetrieveMethod.Copied_dis}
</>
) : (
<>
<Copy className="mr-2 h-4 w-4" /> {messages.text.RetrieveMethod.Copy_QR_dis}
</>
)}
</Button>
<Button onClick={downloadQRCode} variant="outline" className="w-full sm:w-auto">
<Download className="mr-2 h-4 w-4" /> {messages.text.RetrieveMethod.download_QR_dis}
</Button>
</div>
</div>
</div>
{/* QR Code 显示区域 */}
<div className="mt-4 flex justify-center">
<div className="inline-block border-2 p-4 bg-white rounded-lg">
<div ref={qrRef}>
<QRCodeSVG value={shareLink} />
</div>
</div>
</div>
</div>
);
};
export default QRCodeComponent;
@@ -0,0 +1,25 @@
import React from 'react';
import {Progress } from '@/lib/types/file';
interface TransferProgressProps {
message: string;
progress: Progress;
}
//'Sending' : 'Receiving'
const TransferProgress: React.FC<TransferProgressProps> = ({ message, progress }) => {
const speed = isNaN(progress.speed) ? 0 : progress.speed;
return (
<span className="mr-2 text-sm whitespace-nowrap">
{message}
<span className="inline-block min-w-[80px] text-right">
{(speed/1024).toFixed(2)} MB/s
</span>
<span className="inline-block min-w-[50px] text-right">
{(progress.progress * 100).toFixed(0).padStart(2, '0')}%
</span>
</span>
);
};
export default TransferProgress;
@@ -0,0 +1,72 @@
"use client";
import React, { useState, useEffect } from 'react';
import { Play } from 'lucide-react';
interface YouTubePlayerProps {
videoId: string;
className?: string;
}
const YouTubePlayer: React.FC<YouTubePlayerProps> = ({
videoId,
className = ''
}) => {
const [isPlaying, setIsPlaying] = useState<boolean>(false);
const [thumbnailLoaded, setThumbnailLoaded] = useState<boolean>(false);
const [currentThumbnail, setCurrentThumbnail] = useState<string>('');
const thumbnailUrl = `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
const fallbackUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
const embedUrl = `https://www.youtube.com/embed/${videoId}?autoplay=1`;
useEffect(() => {
const img = new Image();
img.onload = () => {
setCurrentThumbnail(img.src);
setThumbnailLoaded(true);
};
img.onerror = () => {
img.src = fallbackUrl;
};
img.src = thumbnailUrl;
}, [videoId, thumbnailUrl, fallbackUrl]);
return (
<div className={`relative w-full max-w-5xl mx-auto ${className}`}>
<div className="relative pb-[56.25%]">
{!isPlaying ? (
<div className="absolute top-0 left-0 w-full h-full">
{currentThumbnail && (
<img
src={currentThumbnail}
alt="Video thumbnail"
className="w-full h-full object-cover"
/>
)}
{thumbnailLoaded && (
<button
onClick={() => setIsPlaying(true)}
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2
bg-black bg-opacity-70 hover:bg-opacity-90 rounded-full p-4
transition-all duration-300 ease-in-out z-10"
aria-label="Play video"
>
<Play size={48} className="text-white" />
</button>
)}
</div>
) : (
<iframe
src={embedUrl}
className="absolute top-0 left-0 w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
title="YouTube video player"
/>
)}
</div>
</div>
);
};
export default YouTubePlayer;
@@ -0,0 +1,125 @@
import React, { useState,useEffect } from 'react';
import { Clipboard, FileText, Check } from 'lucide-react';
import { Button } from "@/components/ui/button";
import { getDictionary } from '@/lib/dictionary';
import { useLocale } from '@/hooks/useLocale';
import type { Messages } from '@/types/messages';
//type==0 --> 剪贴板样式,type!=0 --> 纯文本样式
interface WriteClipboardButtonProps {
title: string;
textToCopy: string;
}
interface ReadClipboardButtonProps {
title: string;
onRead: (text: string) => void;
}
export const WriteClipboardButton: React.FC<WriteClipboardButtonProps> = ({ title, textToCopy }) => {
const locale = useLocale();
const [messages, setMessages] = useState<Messages | null>(null);
const [isCopied, setIsCopied] = useState<boolean>(false);
useEffect(() => {
getDictionary(locale)
.then(dict => setMessages(dict))
.catch(error => console.error('Failed to load messages:', error));
}, [locale]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(textToCopy);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (err) {
console.error('Failed to copy text: ', err);
}
};
if (messages === null) {
return <div>Loading...</div>;
}
return (
<Button variant="outline" onClick={handleCopy}>
{isCopied ? (
<>
<Check className="w-4 h-4 mr-2" />
{messages.text.clipboard_btn.Copied_dis}
</>
): (
<>
<FileText className="mr-2 h-4 w-4" /> {title}
</>
)}
</Button>
);
};
export const ReadClipboardButton: React.FC<ReadClipboardButtonProps> = ({ title, onRead }) => {
const [isReaded, setIsReaded] = useState<boolean>(false);
const locale = useLocale();
const [messages, setMessages] = useState<Messages | null>(null);
useEffect(() => {
getDictionary(locale)
.then(dict => setMessages(dict))
.catch(error => console.error('Failed to load messages:', error));
}, [locale]);
const handleRead = async () => {
try {
// 尝试读取富文本内容
const clipboardItems = await navigator.clipboard.read();
for (const clipboardItem of clipboardItems) {
// 优先尝试读取 HTML 格式
if (clipboardItem.types.includes('text/html')) {
const blob = await clipboardItem.getType('text/html');
const html = await blob.text();
onRead(html);
setIsReaded(true);
setTimeout(() => setIsReaded(false), 2000);
return;
}
// 如果没有 HTML 格式,尝试读取富文本格式
if (clipboardItem.types.includes('text/plain')) {
const blob = await clipboardItem.getType('text/plain');
const text = await blob.text();
// 将换行符转换为 HTML 换行标签
const formattedText = text.replace(/\n/g, '<br>');
onRead(formattedText);
setIsReaded(true);
setTimeout(() => setIsReaded(false), 2000);
return;
}
}
} catch (err) {
// 如果新 API 不支持,回退到传统的 readText 方法
try {
const text = await navigator.clipboard.readText();
const formattedText = text.replace(/\n/g, '<br>');
onRead(formattedText);
setIsReaded(true);
setTimeout(() => setIsReaded(false), 2000);
} catch (fallbackErr) {
console.error('Failed to read clipboard: ', fallbackErr);
onRead('');
}
}
};
if (messages === null) {
return <div>Loading...</div>;
}
return (
<Button variant="outline" onClick={handleRead}>
{isReaded ? (
<>
<Check className="w-4 h-4 mr-2" />
{messages.text.clipboard_btn.Pasted_dis}
</>
) : (
<>
<Clipboard className="w-4 h-4 mr-2" /> {title}
</>
)}
</Button>
);
};
@@ -0,0 +1,254 @@
import React, { useState, useEffect, ChangeEvent, useRef, useCallback } from 'react';
import { Input } from "@/components/ui/input";
import { Upload } from 'lucide-react';
import {FileMeta,CustomFile } from '@/lib/types/file';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
// 在文件顶部添加这个声明来扩展已有的类型,避免IDE报错
declare module "@/components/ui/input" {
interface InputProps {
webkitdirectory?: string | boolean;
directory?: string | boolean;
}
}
import {formatFileChosen } from '@/utils/formatMessage';
import { getDictionary } from '@/lib/dictionary';
import { useLocale } from '@/hooks/useLocale';
import type { Messages } from '@/types/messages';
import { en } from '@/constants/messages/en'; // 导入英文字典作为默认值
const traverseFileTree = async (item: FileSystemEntry, path = ''): Promise<CustomFile[]> => {
return new Promise((resolve) => {
// console.log('path',path)//path in ['','test/','test/sub/']
if (item.isFile) {
(item as FileSystemFileEntry).file((file: File) => {
// console.log('file.name',file.name)//file.name in ['Gmail-773240713232313363.txt','link.txt','cvat-serverless部署踩坑及部署模型测试 (1).docx','images.jpg']
// console.log('fullName',path + file.name,'folderName',path.split('/')[0])
const customFile: CustomFile = Object.assign(file, { fullName: path + file.name, folderName: path.split('/')[0] });
resolve([customFile]);
});
} else if (item.isDirectory) {
const dirReader = (item as FileSystemDirectoryEntry).createReader();
let entries: FileSystemEntry[] = [];
const readEntries = () => {
dirReader.readEntries(async (results) => {
if (results.length) {
entries = entries.concat(Array.from(results));
readEntries();
} else {
const newPath = path + item.name + '/';
const subResults = await Promise.all(
entries.map((entry) => traverseFileTree(entry, newPath))
);
// console.log('subResults',subResults)
const files: CustomFile[] = subResults.flat();
// console.log('files',files)
resolve(files); // 移除了条件判断,直接返回处理好的文件
}
});
};
readEntries();
}
});
};
interface FileUploadHandlerProps {
onFilePicked: (files: CustomFile[]) => void;
}
const FileUploadHandler: React.FC<FileUploadHandlerProps> = ({
onFilePicked
}) => {
const locale = useLocale();
const [messages, setMessages] = useState<Messages>(en); // 使用英文字典作为初始值
const dropZoneRef = useRef<HTMLDivElement>(null);//拖拽文件至附件--支持
const folderInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// 文件选择器--消息提示
const [fileText, setFileText] = useState<string>(en.text.fileUploadHandler.NoFileChosen_tips);
const [isModalOpen, setIsModalOpen] = useState(false);
useEffect(() => {
if (locale !== 'en') { // 如果不是英文,才需要加载其他语言包
getDictionary(locale)
.then(dict => {setMessages(dict);setFileText(dict.text.fileUploadHandler.NoFileChosen_tips);})
.catch(error => console.error('Failed to load messages:', error));
}
}, [locale]);
const handleFileChange = useCallback((newFiles: CustomFile[]) => {
// console.log(newFiles);
onFilePicked(newFiles);
const fileNum = newFiles.length;
const folderNum = newFiles.filter(file => file.folderName).length;
// 使用时
const choose_dis = formatFileChosen(
messages!.text.fileUploadHandler.fileChosen_tips_template, fileNum, folderNum
);
setFileText(choose_dis);
setTimeout(() => setFileText(messages!.text.fileUploadHandler.NoFileChosen_tips), 2000);
// Reset the file input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}, [messages,onFilePicked]);
//拖拽上传文件夹 响应处理
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
const items = e.dataTransfer.items;
if (items) {
const itemsArray = Array.from(items);
Promise.all(itemsArray.map(item => {
const entry = item.webkitGetAsEntry();
if (entry) {
return traverseFileTree(entry);
}
return Promise.resolve([]);
})).then(results => {
const allFiles = results.flat();
handleFileChange(allFiles);
});
}
}, [handleFileChange]);
/* 定义一个处理拖动文件悬停事件的回调函数 handleDragOver。
在 handleDragOver 中,阻止默认行为和事件传播,以确保自定义处理。
没有依赖项数组,这意味着 handleDragOver 函数只会在组件第一次渲染时创建一次,并且不会在之后的渲染中重新创建
*/
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
}, []);
//点击上传文件 处理
const handleFileInputChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files);
let files2 = [];
for(let file of files){
const customFile: CustomFile = Object.assign(file, { fullName: file.name, folderName: '' });
files2.push(customFile);
}
handleFileChange(files2);
setIsModalOpen(false);//关闭对话框
}
}, [handleFileChange]);
//点击上传文件夹 响应处理
const handleFolderInputChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files_ = Array.from(e.target.files);
let files:CustomFile[] = [];
files_.forEach(file => {
// console.log('file.webkitRelativePath',file.webkitRelativePath)//[test/Gmail-773240713232313363.txt,test/link.txt,test/sub/cvat-serverless部署踩坑及部署模型测试 (1).docx,test/sub/images.jpg]
const pathParts = file.webkitRelativePath.split('/');
const customFile: CustomFile = Object.assign(file, { fullName: file.webkitRelativePath, folderName: pathParts[0] });
files.push(customFile);
});
handleFileChange(files);
setIsModalOpen(false);//关闭对话框
}
}, [handleFileChange]);
// 处理拖放区域的点击
const handleZoneClick = () => {
setIsModalOpen(true);
};
// 处理选择文件
const handleSelectFile = () => {
fileInputRef.current?.click();
};
// 处理选择文件夹
const handleSelectFolder = () => {
folderInputRef.current?.click();
};
if (messages === null) {
return <div>Loading...</div>;
}
return (
<>
<div
ref={dropZoneRef}
onDrop={handleDrop}
onDragOver={handleDragOver}
className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center cursor-pointer"
onClick={handleZoneClick}
>
<p className="text-sm text-gray-600 mb-4">
{messages.text.fileUploadHandler.Drag_tips}
</p>
<Upload className="h-12 w-12 mx-auto mb-4 text-blue-500" />
<p className="text-sm text-gray-600">{fileText}</p>
<Input
id="file-upload"
type="file"
onChange={handleFileInputChange}
multiple
className="hidden"
ref={fileInputRef}
/>
<Input
id="folder-upload"
type="file"
onChange={handleFolderInputChange}
multiple
webkitdirectory=""
directory=""
className="hidden"
ref={folderInputRef}
/>
</div>
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="text-xl font-semibold">
{messages.text.fileUploadHandler.chosenDiagTitle}
</DialogTitle>
<DialogDescription className="mt-2 text-muted-foreground">
{messages.text.fileUploadHandler.chosenDiagDescription}
</DialogDescription>
</DialogHeader>
<div className="flex justify-center gap-4 mt-6">
<button
onClick={handleSelectFile}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
{messages.text.fileUploadHandler.SelectFile_dis}
</button>
<button
onClick={handleSelectFolder}
className="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors"
>
{messages.text.fileUploadHandler.SelectFolder_dis}
</button>
</div>
</DialogContent>
</Dialog>
</>
);
};
export const DownloadAs = async (file:any,saveName:string) => {
const url = URL.createObjectURL(file);
const a = document.createElement('a');
a.href = url;
a.download = saveName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
export { FileUploadHandler };
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useState } from 'react';
// 我们将函数转换为一个自定义 Hook useRichTextToPlainText。这允许我们使用 React 的生命周期方法来检测是否在浏览器环境中。
// 使用 useState 和 useEffect 来检测是否在浏览器环境中。useEffect 只在客户端运行,所以我们可以安全地在其中设置 isBrowser 为 true。
function useRichTextToPlainText() {
const [isBrowser, setIsBrowser] = useState(false);
useEffect(() => {
setIsBrowser(true);
}, []);
const richTextToPlainText = (richText: string): string => {
if (!isBrowser) {
return richText; // 在服务器端,直接返回原文本
}
// 创建一个临时的DOM元素
const tempElement = document.createElement("div");
// 将富文本内容设置为临时元素的innerHTML
tempElement.innerHTML = richText;
// 处理直接的文本节点(不在任何块级元素内的文本)
// 将它们包装在 div 中以保持一致的处理
const wrapTextNodes = (element: HTMLElement) => {
const childNodes = Array.from(element.childNodes);
childNodes.forEach(node => {
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
const wrapper = document.createElement('div');
wrapper.textContent = node.textContent;
node.replaceWith(wrapper);
}
});
};
wrapTextNodes(tempElement);
// 处理所有块级元素
const blockElements = ['div', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre'];
blockElements.forEach(tag => {
tempElement.querySelectorAll(tag).forEach(element => {
// 如果元素内容为空或只包含 <br>,则替换为双换行
if (!element.textContent?.trim() || element.innerHTML === '<br>') {
element.replaceWith('\n\n');
} else {
// 否则在内容后添加换行
element.replaceWith(element.textContent + '\n');
}
});
});
// 处理 <br> 标签
tempElement.querySelectorAll('br').forEach(br => {
br.replaceWith('\n');
});
// 获取并处理纯文本
let plainText = tempElement.textContent || tempElement.innerText || '';
// 处理连续的换行符
plainText = plainText
.replace(/\n{3,}/g, '\n\n') // 将3个以上连续换行符替换为2个
.replace(/^\n+/, '') // 删除开头的换行符
.replace(/\n+$/, '') // 删除结尾的换行符
.trim(); // 删除首尾空格
return plainText;
};
return richTextToPlainText;
}
export default useRichTextToPlainText;
+58
View File
@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+56
View File
@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
+79
View File
@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+30
View File
@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
+122
View File
@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
+200
View File
@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
+160
View File
@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+24
View File
@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }
+129
View File
@@ -0,0 +1,129 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}
+35
View File
@@ -0,0 +1,35 @@
"use client"
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useToast } from "@/components/ui/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}
+30
View File
@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+194
View File
@@ -0,0 +1,194 @@
"use client"
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }
+20
View File
@@ -0,0 +1,20 @@
import { setTrack } from '@/app/config/api';
//网站通过?ref=reddit...来追踪来源,这里获取来源,样例https://yourdomain.com?ref=producthunt
export const trackReferrer = async () => {
// 获取 URL 参数
const urlParams = new URLSearchParams(window.location.search);
let ref = urlParams.get('ref');
if (process.env.NEXT_PUBLIC_development === 'false'){
ref = urlParams.get('ref') || 'noRef';//生产环境,统计日活,没有ref记录为noRef
}
const path = window.location.pathname;
if (ref) {
try {
setTrack(ref,path);
// 可选:将来源存储在 localStorage 中,用于后续追踪
// localStorage.setItem('initial_ref', ref);
} catch (error) {
console.error('Failed to track referrer:', error);
}
}
};
+95
View File
@@ -0,0 +1,95 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion"
import type { Messages } from '@/types/messages';
interface FAQMessage {
[key: string]: string;
}
interface FAQ {
question: string;
answer: string;
}
const generateFAQs = (messages: { text: { faqs: FAQMessage } }): FAQ[] => {
const faqs: FAQ[] = [];
const faqsData = messages.text.faqs;
// 获取所有问题的数量(通过查找 question_ 开头的键)
const questionKeys = Object.keys(faqsData).filter(key => key.startsWith('question_'));
// 根据问题数量自动生成FAQ数组
questionKeys.forEach(qKey => {
const index = qKey.split('_')[1]; // 获取数字索引
const aKey = `answer_${index}`;
if (faqsData[aKey]) { // 确保对应的答案存在
faqs.push({
question: faqsData[qKey],
answer: faqsData[aKey]
});
}
});
return faqs;
};
interface FAQSectionProps {
isMainPage?: boolean; // 是否为主页面的FAQ部分
className?: string; // 允许传入自定义className
showTitle?: boolean; // 是否显示标题
titleClassName?: string; // 标题样式类
lang?: string;
messages: Messages;
}
//通过 props 来控制标题的级别和样式,这样可以用在其他页面也可以用在独立页面
export default function FAQSection({
isMainPage = false,
className = "",
showTitle = true,
titleClassName = "",
messages
}: FAQSectionProps) {
const faqs = generateFAQs(messages);
// 为不同场景设置默认样式
const containerClasses = `container mx-auto px-4 py-8 ${className}`;
const defaultTitleClasses = "font-bold mb-8";
const titleClasses = `${defaultTitleClasses} ${titleClassName}`.trim();
return (
<div className={containerClasses}>
{showTitle && (
isMainPage ? (
<h2 className={`text-3xl ${titleClasses}`}>{messages.text.faqs.FAQ_dis}</h2>
) : (
<h1 className={`text-4xl ${titleClasses}`}>{messages.text.faqs.FAQ_dis}</h1>
)
)}
<Accordion type="single" collapsible className="w-full">
{faqs.map((faq, index) => (
<AccordionItem key={index} value={`item-${index}`}>
<AccordionTrigger>{faq.question}</AccordionTrigger>
<AccordionContent>{faq.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
)
}
// // 在独立的FAQ页面
// <FAQSection /> // 使用 h1 标签
// // 在首页
// <FAQSection
// isMainPage
// titleClassName="text-2xl md:text-3xl" // 可选:在首页使用稍小的字号
// /> // 使用 h2 标签
// // 如果不需要显示标题
// <FAQSection showTitle={false} />
+76
View File
@@ -0,0 +1,76 @@
import Link from 'next/link'
import Image from 'next/image'
import { Messages } from '@/types/messages'
import { languageDisplayNames } from '@/constants/i18n-config';
interface FooterProps {
messages: Messages;
lang: string;
}
export function Footer({ messages, lang }: FooterProps) {
return (
<footer className="bg-background border-t mt-auto">
<div className="container mx-auto px-4 py-6">
<div className="flex flex-col sm:flex-row justify-between items-center space-y-4 sm:space-y-0">
{/* 左侧Logo和版权信息 */}
<div className="flex items-center">
<Image
src="/logo.png"
alt="SecureShare Logo"
width={30}
height={30}
className="mr-2"
priority
/>
<p className="text-sm text-muted-foreground">
&copy; {new Date().getFullYear()} {messages.text.Footer.CopyrightNotice}
</p>
</div>
{/* 右侧导航 */}
<nav>
<ul className="flex flex-wrap justify-center gap-4">
{/* 条款和隐私政策 */}
<li>
<Link
href={`/${lang}/terms`}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{messages.text.Footer.Terms_dis}
</Link>
</li>
<li>
<Link
href={`/${lang}/privacy`}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{messages.text.Footer.Privacy_dis}
</Link>
</li>
{/* 支持的语言入口 */}
<li>
<span className="text-sm text-muted-foreground font-bold">
{messages.text.Footer.SupportedLanguages}:
</span>
</li>
{Object.entries(languageDisplayNames).map(([code, name]) => (
<li key={code}>
<Link
href={`/${code}`}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{name}
</Link>
</li>
))}
</ul>
</nav>
</div>
</div>
</footer>
);
}
export default Footer;
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import Image from 'next/image';
import { Menu, X } from 'lucide-react';
import LanguageSwitcher from '@/components/LanguageSwitcher';
import { Messages } from '@/types/messages'
interface HeaderProps {
messages: Messages;
lang: string;
}
const Header = ({ messages, lang }: HeaderProps) => {
const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
const navItems = [
{ href: `/${lang}`, label: messages.text.Header.Home_dis },
{ href: `/${lang}/blog`, label: messages.text.Header.Blog_dis },
{ href: `/${lang}/about`, label: messages.text.Header.About_dis },
{ href: `/${lang}/help`, label: messages.text.Header.Help_dis },
{ href: `/${lang}/faq`, label: messages.text.Header.FAQ_dis },
{ href: `/${lang}/terms`, label: messages.text.Header.Terms_dis },
{ href: `/${lang}/privacy`, label: messages.text.Header.Privacy_dis },
];
return (
<header className="bg-background border-b sticky top-0 z-50">
<div className="container mx-auto px-4 py-4">
<div className="flex justify-between items-center">
<Link href={`/${lang}`} className="flex items-center space-x-2">
<Image src="/logo.png" alt="SecureShare Logo" width={40} height={40} priority />
<span className="font-bold text-xl hidden sm:inline">SecureShare</span>
</Link>
{/* 桌面端导航和语言切换 */}
<div className="hidden md:flex items-center space-x-4">
<nav>
<ul className="flex space-x-2">
{navItems.map((item) => (
<li key={item.href}>
<Button
asChild
variant="ghost"
size="sm"
className={cn(
"hover:bg-muted",
pathname === item.href && "bg-muted"
)}
>
<Link href={item.href}>{item.label}</Link>
</Button>
</li>
))}
</ul>
</nav>
<LanguageSwitcher />
</div>
{/* 移动端菜单按钮 */}
<div className="md:hidden flex items-center space-x-2">
<LanguageSwitcher />
<button
className="p-2"
onClick={() => setIsOpen(!isOpen)}
aria-label="Toggle menu"
>
{isOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
</div>
{/* 移动端导航菜单 */}
{isOpen && (
<nav className="md:hidden mt-4">
<ul className="flex flex-col space-y-2">
{navItems.map((item) => (
<li key={item.href}>
<Button
asChild
variant="ghost"
className={cn(
"w-full justify-start",
pathname === item.href && "bg-muted"
)}
onClick={() => setIsOpen(false)}
>
<Link href={item.href}>{item.label}</Link>
</Button>
</li>
))}
</ul>
</nav>
)}
</div>
</header>
);
};
export default Header;
+79
View File
@@ -0,0 +1,79 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import Image from 'next/image'
import type { Messages } from '@/types/messages';
interface PageContentProps {
messages: Messages;
}
export default function HowItWorks({ messages }: PageContentProps){
const steps = [
{
number: 1,
title: messages!.text.HowItWorks.step1_title,
description: messages!.text.HowItWorks.step1_description
},
{
number: 2,
title: messages!.text.HowItWorks.step2_title,
description: messages!.text.HowItWorks.step2_description
},
{
number: 3,
title: messages!.text.HowItWorks.step3_title,
description: messages!.text.HowItWorks.step3_description
}
];
return (
<section className="max-w-6xl mx-auto px-4 py-16">
{/* Header Section */}
<div className="text-center mb-16">
<h2 className="text-3xl md:text-4xl font-bold mb-6">{messages.text.HowItWorks.h2}</h2>
<p className="text-gray-600 mb-8">{messages.text.HowItWorks.h2_P}</p>
<Button
className="bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600 text-white rounded-full px-8 py-6 text-lg"
>
{messages.text.HowItWorks.btn_try}
</Button>
</div>
{/* Steps Container */}
<div className="flex flex-col md:flex-row justify-between items-start gap-16">
{/* Left Side - Steps */}
<div className="w-full md:w-1/2 relative">
{/* Vertical Line */}
<div className="absolute left-6 top-8 bottom-8 w-0.5 bg-blue-500"></div>
{/* Steps List */}
<div className="space-y-16">
{steps.map((step) => (
<div key={step.number} className="flex gap-6 items-start">
<div className="relative z-10">
<div className="w-12 h-12 rounded-full bg-blue-500 flex items-center justify-center text-white
text-xl font-bold shadow-md transition-transform hover:scale-105">
{step.number}
</div>
</div>
<div className="flex-1">
<h3 className="text-xl font-bold mb-2">{step.title}</h3>
<p className="text-gray-600">{step.description}</p>
</div>
</div>
))}
</div>
</div>
{/* Right Side - Demo Animation */}
<div className="w-full md:w-1/2">
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
{/* Next.js 默认的图片优化器不支持 GIF 动画的处理 */}
<Image src="/HowItWorks.gif" alt="How SecureShare Works" unoptimized width={700} height={921} className="mx-auto mb-6" />
</div>
</div>
</div>
</section>
);
};
+52
View File
@@ -0,0 +1,52 @@
import Image from 'next/image'
import type { Messages } from '@/types/messages';
interface PageContentProps {
messages: Messages;
}
export default function KeyFeatures({ messages }: PageContentProps) {
return (
<section className="mb-12">
<h2 className="text-3xl font-semibold mb-6">{messages.text.KeyFeatures.h2}</h2>
<div className="space-y-6">
<div>
<h3 className="text-xl font-semibold mb-2 flex items-center">
<Image src="/lock.png" alt="Icon" width={80} height={80} />
<span className="ml-6">{messages.text.KeyFeatures.h3_1}</span>
</h3>
<p>{messages.text.KeyFeatures.h3_1_P}</p>
</div>
<div>
<h3 className="text-xl font-semibold mb-2 flex items-center">
<Image src="/teamwork.png" alt="Icon" width={80} height={80} />
<span className="ml-6">{messages.text.KeyFeatures.h3_2}</span>
</h3>
<p>{messages.text.KeyFeatures.h3_2_P}</p>
</div>
<div>
<h3 className="text-xl font-semibold mb-2 flex items-center">
<Image src="/rocket.png" alt="Icon" width={80} height={80} />
<span className="ml-6">{messages.text.KeyFeatures.h3_3}</span>
</h3>
<p>{messages.text.KeyFeatures.h3_3_P}</p>
</div>
<div>
<h3 className="text-xl font-semibold mb-2 flex items-center">
<Image src="/fresh-air.png" alt="Icon" width={80} height={80} />
<span className="ml-6">{messages.text.KeyFeatures.h3_4}</span>
</h3>
<p>{messages.text.KeyFeatures.h3_4_P}</p>
</div>
<div>
<h3 className="text-xl font-semibold mb-2 flex items-center">
<Image src="/planet-earth.png" alt="Icon" width={80} height={80} />
<span className="ml-6">{messages.text.KeyFeatures.h3_5}</span>
</h3>
<p>{messages.text.KeyFeatures.h3_5_P}</p>
</div>
</div>
</section>
)
}
+21
View File
@@ -0,0 +1,21 @@
import Image from 'next/image'
import type { Messages } from '@/types/messages';
interface PageContentProps {
messages: Messages;
}
export default function SystemDiagram({ messages }: PageContentProps) {
return (
<section className="py-16 bg-background">
<div className="container mx-auto px-4">
<h2 className="text-3xl font-bold mb-12 text-center">{messages.text.SystemDiagram.h2}</h2>
<Image src="/SystemDiagram.png" alt="SecureShare system diagram: Peer-to-peer file and clipboard sharing" width={1226} height={745} className="mx-auto mb-6" />
<p className="mt-8 text-center max-w-2xl mx-auto">
{messages.text.SystemDiagram.h2_P}
</p>
</div>
</section>
)
}
@@ -0,0 +1,9 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes/dist/types"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
+20
View File
@@ -0,0 +1,20 @@
export const i18n = {
defaultLocale: 'en' as const,
locales: ['en', 'zh', 'ja', 'es', 'de', 'fr', 'ko'] as const,
}
export type Locale = (typeof i18n)['locales'][number]
// 导出语言列表
export const supportedLocales = i18n.locales;
// 语言名称映射--下拉列表选择语言
export const languageDisplayNames = {
en: 'English', // 英语
zh: '中文', // 中文
es: 'Español', // 西班牙语
ja: '日本語', // 日本语
de: 'Deutsch', // 德语
fr: 'Français', // 法语
ko: '한국어', // 韩语
};
+265
View File
@@ -0,0 +1,265 @@
import { Messages } from '@/types/messages'
export const de: Messages = {
meta: {
home: {
title: "SecureShare: Kostenloser P2P-Datei- & Clipboard-Sharing | Privat & Ohne Upload",
description: "SecureShare ermöglicht sofortiges, sicheres P2P-Datei-Sharing ohne Größenbeschränkungen oder Registrierung. Teilen Sie Text, Bilder, Ordner geräteübergreifend mit Ende-zu-Ende-Verschlüsselung. Perfekt für Teamarbeit und private Dateiübertragungen.",
keywords: 'Dateifreigabe, sichere Dateiübertragung, P2P-Dateiübertragung, WebRTC-Dateifreigabe, privater Clipboard, Teamarbeit, geräteübergreifende Freigabe, verschlüsselte Dateiübertragung, Dateifreigabe ohne Registrierung, unbegrenzte Dateiübertragung, Ordnersynchronisation, mobile Dateiübertragung, sichere Nachrichtenübermittlung, sofortige Dateifreigabe, private Datenübertragung',
},
about: {
title: "Über SecureShare",
description: "Erfahren Sie mehr über SecureShare, unsere Mission, einen sicheren und privaten Dateiübertragungs- und Clipboard-Sharing-Dienst bereitzustellen, und unser Engagement für den Datenschutz und den Schutz der Benutzerdaten."
},
faq:{
title: "SecureShare FAQ",
description: "Finden Sie Antworten auf häufig gestellte Fragen zu SecureShare, einschließlich der Frage, wie Sie Dateien senden, Clipboard-Inhalte teilen und sichere und private Datenübertragungen gewährleisten können.",
keywords: 'SecureShare FAQ, häufig gestellte Fragen, sichere Dateifreigabe FAQ, Hilfe zur privaten Datenfreigabe, Ende-zu-Ende-verschlüsselte Dateiübertragung, Support für sicheres Clipboard-Sharing, wie man SecureShare verwendet, Dateiübertragungs-FAQ, Fragen zur datenschutzorientierten Freigabe, SecureShare-Problembehandlung',
},
help: {
title: "SecureShare Hilfe und Support",
description: "Finden Sie Informationen darüber, wie Sie den SecureShare-Support kontaktieren können, sowie Links zu unseren Seiten Über uns, Nutzungsbedingungen und Datenschutzrichtlinien für weitere Details zu unserem Dienst."
},
privacy: {
title: "SecureShare Datenschutzrichtlinie",
description: "Verstehen Sie, wie SecureShare Ihre Privatsphäre und Daten schützt, einschließlich Details zur Informationssammlung, Datenspeicherung und -sicherheit sowie unserem Engagement, Ihre Daten nicht an Dritte weiterzugeben."
},
terms: {
title: "SecureShare Nutzungsbedingungen",
description: "Überprüfen Sie die Nutzungsbedingungen für SecureShare, einschließlich Informationen zur akzeptablen Nutzung des Dienstes, Datenschutz und -sicherheit sowie Haftungsbeschränkungen."
},
},
text: {
Header:{
Home_dis:"Startseite",
Blog_dis: "Blog",
About_dis:"Über uns",
Help_dis:"Hilfe",
FAQ_dis:"FAQ",
Terms_dis:"Nutzungsbedingungen",
Privacy_dis:"Datenschutz",
},
Footer:{
CopyrightNotice:"SecureShare. Alle Rechte vorbehalten.",
Terms_dis:"Nutzungsbedingungen",
Privacy_dis:"Datenschutzrichtlinie",
SupportedLanguages:"Unterstützte Sprachen"
},
privacy:{
PrivacyPolicy_dis:"Datenschutzrichtlinie",
h1:"SecureShare Datenschutzrichtlinie",
h1_P:"Bei SecureShare sind wir bestrebt, Ihre Privatsphäre zu schützen und Ihre persönlichen Daten zu sichern. Diese Datenschutzrichtlinie beschreibt, wie wir die Daten, die Sie bei der Nutzung unseres Dienstes bereitstellen, sammeln, verwenden und schützen.",
h2_1:"Informationssammlung",
h2_1_P:"SecureShare sammelt keine personenbezogenen Daten von Benutzern. Wir verlangen keine Registrierung oder Kontoerstellung, um unseren Dienst zu nutzen. Die einzigen Informationen, die wir sammeln, sind die Raum-ID und die Datei-/Clipboard-Daten, die Sie mit anderen Benutzern teilen möchten.",
h2_2:"Datenspeicherung und -sicherheit",
h2_2_P:"Wir speichern keine Ihrer Daten auf unseren Servern. Alle Dateiübertragungen und Clipboard-Freigaben werden mit Ende-zu-Ende-Verschlüsselung verarbeitet, um sicherzustellen, dass Ihre Informationen sicher und nur für den vorgesehenen Empfänger zugänglich bleiben. Sobald die Übertragung abgeschlossen ist, werden die Daten aus unseren Systemen entfernt.",
h2_3:"Drittanbieterdienste",
h2_3_P:"SecureShare integriert keine Drittanbieterdienste oder -plattformen. Wir geben oder verkaufen Ihre Daten nicht an Dritte weiter.",
h2_4:"Änderungen der Datenschutzrichtlinie",
h2_4_P:"Wir können diese Datenschutzrichtlinie von Zeit zu Zeit aktualisieren, um Änderungen in unseren Praktiken oder geltenden Gesetzen widerzuspiegeln. Alle Änderungen werden wirksam, sobald die aktualisierte Richtlinie auf unserer Website veröffentlicht wird. Es liegt in Ihrer Verantwortung, die Datenschutzrichtlinie regelmäßig auf Aktualisierungen zu überprüfen.",
h2_5:"Kontaktieren Sie uns",
h2_5_P:"Wenn Sie Fragen oder Bedenken zu unseren Datenschutzpraktiken haben, kontaktieren Sie uns bitte unter",
},
terms:{
TermsOfUse_dis:"Nutzungsbedingungen",
h1:"SecureShare Nutzungsbedingungen",
h1_P:"Durch die Nutzung des SecureShare-Dienstes erklären Sie sich mit diesen Nutzungsbedingungen einverstanden. Wenn Sie diesen Bedingungen nicht zustimmen, nutzen Sie den Dienst bitte nicht.",
h2_1:"Nutzung des Dienstes",
h2_1_P:"SecureShare wird als kostenloser Dienst ohne Einschränkungen bereitgestellt.",
h2_2:"Datenschutz und -sicherheit",
h2_2_P:"Wir nehmen den Schutz und die Sicherheit Ihrer Daten sehr ernst. Alle Dateiübertragungen und Clipboard-Freigaben werden mit Ende-zu-Ende-Verschlüsselung gesichert, und wir speichern keine Ihrer Daten auf unseren Servern. Wir können jedoch die Sicherheit Ihrer Daten während des Übertragungsprozesses nicht garantieren, und Sie nutzen den Dienst auf eigenes Risiko.",
h2_3:"Akzeptable Nutzung",
h2_3_P:"Sie erklären sich damit einverstanden, SecureShare nicht für unlautere, missbräuchliche oder schädliche Zwecke zu verwenden. Dies umfasst, ist aber nicht beschränkt auf die Übertragung von illegalen, urheberrechtlich geschützten oder bösartigen Inhalten sowie die Nutzung des Dienstes, um andere zu belästigen oder zu imitieren.",
h2_4:"Haftungsbeschränkung",
h2_4_P:"SecureShare wird „wie besehen“ ohne jegliche Gewährleistungen oder Garantien bereitgestellt. Wir haften nicht für direkte, indirekte oder Folgeschäden, die aus der Nutzung unseres Dienstes entstehen, einschließlich, aber nicht beschränkt auf Datenverlust, Systemausfälle oder Dienstunterbrechungen.",
h2_5:"Änderungen der Nutzungsbedingungen",
h2_5_P:"Wir behalten uns das Recht vor, diese Nutzungsbedingungen jederzeit zu aktualisieren. Alle Änderungen werden wirksam, sobald die aktualisierten Bedingungen auf unserer Website veröffentlicht werden. Es liegt in Ihrer Verantwortung, die Nutzungsbedingungen regelmäßig auf Änderungen zu überprüfen.",
},
help:{
Help_dis:"Hilfe",
h1:"SecureShare Hilfe und Support",
h1_P:"Wir sind hier, um Ihnen zu helfen, das Beste aus SecureShare herauszuholen. Wenn Sie Fragen haben oder Unterstützung benötigen, zögern Sie bitte nicht, uns zu kontaktieren.",
h2_1:"Kontaktieren Sie uns",
h2_1_P1:"Sie können uns eine E-Mail senden an",
h2_1_P2:". Wir werden uns innerhalb von 24 Stunden bei Ihnen melden.",
h2_2:"Soziale Medien",
h2_2_P:"Sie können uns auch in sozialen Medien finden:",
h2_3:"Zusätzliche Ressourcen",
h2_3_P:"Weitere Informationen zu SecureShare finden Sie auf den folgenden Seiten:",
},
about:{
h1:"Über SecureShare",
P1:"SecureShare ist ein kostenloses und sicheres Dateiübertragungs- und Clipboard-Sharing-Tool, das mit Fokus auf Privatsphäre und Benutzerfreundlichkeit entwickelt wurde. Unsere Mission ist es, eine einfache, aber leistungsstarke Lösung für die Übertragung von Dateien und die Freigabe von Inhalten geräteübergreifend ohne Einschränkungen bereitzustellen.",
P2:"Im Kern von SecureShare steht unser Engagement für Sicherheit und Privatsphäre. Wir verwenden Ende-zu-Ende-Verschlüsselung, um sicherzustellen, dass Ihre Daten während des Übertragungsprozesses geschützt sind, und wir speichern Ihre Dateien oder Clipboard-Inhalte niemals auf unseren Servern. Dies bedeutet, dass Ihre Daten lokal und unter Ihrer Kontrolle bleiben.",
P3:"Mit SecureShare können Sie mühelos Text, Bilder und Dateien jeder Größe teilen, ohne Registrierung oder Anmeldung. Unsere Plattform ist darauf ausgelegt, schnell, effizient und umweltfreundlich zu sein, mit einem Fokus auf ein nahtloses und benutzerfreundliches Erlebnis.",
P4:"Wir glauben daran, Benutzern die Kontrolle über ihr digitales Leben zu geben, und SecureShare ist unser Beitrag zu dieser Vision. Wir hoffen, dass unser Tool Ihnen hilft, sicher mit Freunden, Familie und Kollegen zu teilen und zusammenzuarbeiten, ohne Ihre Privatsphäre oder Sicherheit zu gefährden.",
P5:"Weitere Informationen oder Fragen finden Sie auf den folgenden Seiten:"
},
HowItWorks:{
h2: "Wie es funktioniert",
h2_P: "Teilen Sie Dateien und Nachrichten sofort in drei einfachen Schritten",
btn_try: "Jetzt ausprobieren →",
step1_title:"Text eingeben oder Dateien auswählen",
step1_description:"Geben Sie Ihre Nachricht ein oder ziehen Sie Dateien/Ordner in den Auswahlbereich",
step2_title:"Raum beitreten",
step2_description:"Klicken Sie auf die Schaltfläche 'Raum beitreten', um eine Freigabesitzung zu erstellen",
step3_title:"Empfangen",
step3_description:"Geben Sie die Raum-ID auf der Empfangsseite ein und klicken Sie auf 'Raum beitreten', um die freigegebenen Inhalte zu erhalten",
},
SystemDiagram:{
h2: "Systemdiagramm",
h2_P: "SecureShare: Ihre Daten, Ihre Kontrolle. Einfach, schnell und privat.",
},
KeyFeatures:{
h2: "Hauptmerkmale",
h3_1: "Direkt und sicher",
h3_1_P: "Ihre Dateien reisen direkt von Ihrem Gerät zum Empfänger, wie ein geheimer Tunnel, den nur Sie beide nutzen können. Mit Ende-zu-Ende-Verschlüsselung ist es, als ob Ihre Daten eine Sprache sprechen, die nur der vorgesehene Empfänger verstehen kann. Möchten Sie nicht mehr teilen? Schließen Sie einfach Ihren Browser-Tab, und es ist, als ob Sie ein Telefongespräch beenden Sie haben die Kontrolle.",
h3_2: "Team-Synergie",
h3_2_P: "Teilen Sie mit Ihrem gesamten Team so einfach wie mit einer Person. Wie bei einem digitalen Rundtisch erhalten alle die Dateien gleichzeitig. Egal, ob Sie an einem kreativen Projekt zusammenarbeiten oder wichtige Dokumente verteilen, es ist, als ob alle im selben Raum wären und Ihre gemeinsame Vision gleichzeitig erhalten. Perfekt für Brainstorming-Sitzungen, Team-Präsentationen oder jeden Moment, in dem mehrere Köpfe verbunden sein müssen.",
h3_3: "Keine Grenzen, intelligente Handhabung",
h3_3_P: "Stellen Sie sich eine magische Pipeline vor, die alles transportieren kann, egal wie groß! Senden Sie Dateien jeder Größe, begrenzt nur durch Ihren Speicherplatz. Für besonders große Dateien können Sie auswählen, wo Sie sie auf Ihrem Gerät speichern möchten. Es ist wie ein spezieller Lieferdienst, der Ihren Computer nicht verlangsamt Dateien gehen direkt auf die Festplatte, sodass Ihr Gerät schnell und reaktionsschnell bleibt.",
h3_4: "Schnell wie ein Gedanke",
h3_4_P: "Teilen Sie Text, Bilder und sogar ganze Ordner so schnell, wie Sie daran denken können. Es ist, als ob Sie Ihre digitalen Sachen sofort teleportieren. Müssen Sie ein ganzes Fotoalbum oder einen Ordner voller Dokumente senden? Kein Problem! Es ist so einfach wie das Teilen einer einzelnen Datei.",
h3_5: "Grün und sauber",
h3_5_P: "Wir sind wie eine digitale Version eines persönlichen Gesprächs nichts wird woanders gespeichert. Das bedeutet, dass wir sehr umweltfreundlich sind und minimale Ressourcen verwenden. Es ist, als ob wir keine Spuren in der digitalen Welt hinterlassen und alles sauber und grün für alle halten.",
},
faqs:{
FAQ_dis:"Häufig gestellte Fragen",
question_0: "Werden die Daten wirklich lokal gespeichert und nicht auf andere Server übertragen?",
answer_0: "Ja, alle Daten werden lokal verarbeitet. Sie können das YouTube-Video auf unserer Startseite ansehen Dateien können auch in einem lokalen Netzwerk übertragen werden, selbst wenn das Internet nach dem Herstellen der Verbindung getrennt wird. In Zukunft planen wir, den Code zu öffnen, damit jeder ihn überprüfen kann.",
question_1: "Wie sende und empfange ich Ordner?",
answer_1: "Das Senden eines Ordners ist so einfach wie das Senden einer Datei. Ziehen Sie den Ordner in den Dateiauswahlbereich oder klicken Sie auf den Bereich, um ihn auszuwählen, und drücken Sie dann auf „Senden starten“. Auf der Empfängerseite können Benutzer direkt herunterladen oder ein Speicherverzeichnis auswählen, bevor sie herunterladen. Ersteres speichert im Speicher, während Letzteres direkt auf die Festplatte speichert.",
question_2: "Kann ich die Raum-ID ändern?",
answer_2: "Ja, Sie können die Raum-ID in eine beliebige Zeichenfolge ändern, die Sie bevorzugen.",
question_3: "Kann ich Inhalte kontinuierlich teilen?",
answer_3: "Solange Sie verbunden bleiben, können Sie manuell auf die Schaltfläche „Senden starten“ klicken, um die freigegebenen Inhalte zu aktualisieren, sobald sie sich ändern.",
question_4: "Kann ich Dateien gleichzeitig mit mehreren Empfängern teilen?",
answer_4: "Natürlich! Es gibt keinen Unterschied zwischen einem Empfänger und mehreren Empfängern, die gleichzeitig empfangen.",
question_5: "Sind meine Daten sicher, wenn ich SecureShare verwende?",
answer_5: "Absolut sicher. Ihre Daten bleiben immer lokal und werden zwischen Geräten über eine verschlüsselte Ende-zu-Ende-Verbindung übertragen. Alle übertragenen Daten sind verschlüsselt, sodass nur Sie und der Empfänger darauf zugreifen können.",
question_6: "Muss ich ein Konto erstellen, um SecureShare zu verwenden?",
answer_6: "Keine Registrierung oder Anmeldung erforderlich öffnen Sie einfach die Website und beginnen Sie mit der Nutzung. Bequemlichkeit und Geschwindigkeit haben für uns Priorität.",
question_7: "Gibt es Einschränkungen bei der Dateigröße?",
answer_7: "Keine Einschränkungen bei der Dateigröße oder Geschwindigkeit. Solange Sie genügend Speicherplatz haben, können Sie Dateien jeder Größe übertragen, indem Sie ein Speicherverzeichnis vor dem Herunterladen festlegen.",
question_8: "Kann ich Ordner oder mehrere Dateien gleichzeitig teilen?",
answer_8: "Ja, das Teilen mehrerer Dateien oder Ordner ist so einfach wie das Teilen einer einzelnen Datei. Sie können auch Dateien zur Übertragung hinzufügen klicken Sie einfach auf „Senden starten“, um sie für den Empfänger zu aktualisieren.",
question_9: "Wie kann ich das Teilen beenden, wenn ich es mir anders überlege?",
answer_9: "Das Beenden einer Freigabe ist so einfach wie das Schließen des Browser-Tabs oder -Fensters. Sobald Sie dies tun, wird die Verbindung beendet, und es können keine weiteren Daten übertragen werden.",
question_10: "Verlangsamt SecureShare mein Gerät?",
answer_10: "Nein, SecureShare ist darauf ausgelegt, leichtgewichtig und effizient zu sein. Wenn Sie ein Speicherverzeichnis festlegen, werden alle empfangenen Daten direkt auf die Festplatte geschrieben, wodurch der Speicher umgangen wird. Dies hilft, die Leistung Ihres Geräts aufrechtzuerhalten.",
question_11: "Kann ich SecureShare offline verwenden?",
answer_11: "Ja, wenn sich Sender und Empfänger im selben lokalen Netzwerk befinden, können sie sich bei bestehender Internetverbindung demselben Raum anschließen und dann die Verbindung trennen. Die Dateifreigabe funktioniert weiterhin. Einzelheiten finden Sie im YouTube-Video auf der Startseite.",
question_12: "Verwendet SecureShare Server?",
answer_12: "Ja, es gibt tatsächlich einen leichtgewichtigen Server, der nur für die Signalisierung verwendet wird, um eine verschlüsselte Verbindung herzustellen. Sobald die Verbindung hergestellt ist, werden alle Daten direkt zwischen den Geräten über die verschlüsselte Verbindung übertragen.",
question_13: "Wie lange ist eine Raum-ID gültig?",
answer_13: "Die anfängliche Gültigkeit einer Raum-ID beträgt 24 Stunden. Wenn ein Empfänger dem Raum beitritt, wird die Gültigkeit automatisch um 24 Stunden ab diesem Zeitpunkt verlängert.",
},
clipboard_btn:{
Pasted_dis:"Eingefügt",
Copied_dis:"Kopiert",
},
fileUploadHandler:{
NoFileChosen_tips:"Keine Datei ausgewählt",
fileChosen_tips_template: "{fileNum} Datei(en) und {folderNum} Ordner ausgewählt",
Drag_tips:"Ziehen Sie Dateien/Ordner hierher oder klicken Sie, um sie auszuwählen",
chosenDiagTitle:"Upload-Typ auswählen",
chosenDiagDescription:"Wählen Sie aus, ob Sie Dateien oder einen Ordner hochladen möchten",
SelectFile_dis:"Dateien auswählen",
SelectFolder_dis:"Ordner auswählen",
},
FileTransferButton:{
SavedToDisk_tips:"Datei bereits auf Festplatte gespeichert",
CurrentFileTransferring_tips:"Datei wird übertragen",
OtherFileTransferring_tips:"Bitte warten Sie, bis die aktuelle Übertragung abgeschlossen ist",
download_tips:"Klicken Sie, um die Datei herunterzuladen",
Saved_dis:"Gespeichert",
Waiting_dis:"Warten",
Download_dis:"Herunterladen",
},
FileListDisplay:{
sending_dis: 'Senden',
receiving_dis: 'Empfangen',
finish_dis:"abgeschlossen",
delete_dis:"Löschen",
downloadNum_dis:"Anzahl der Downloads",
folder_tips_template:"Ordnername: {name} ({num} Dateien und {size}) insgesamt",
folder_dis_template:" ({num} Dateien, {size})",
PopupDialog_title:"Empfohlen: Speicherverzeichnis auswählen",
PopupDialog_description:"Wir empfehlen, ein Speicherverzeichnis auszuwählen, um Dateien direkt auf Ihre Festplatte zu speichern. Dies erleichtert die Übertragung großer Dateien und die effiziente Synchronisierung von Ordnern.",
chooseSavePath_tips:"Speichern Sie große Dateien oder Ordner direkt in einem ausgewählten Verzeichnis. 👉",
chooseSavePath_dis:"Speicherort auswählen",
},
RetrieveMethod:{
P:"Glückwunsch 🎉 Freigegebene Inhalte warten darauf, abgerufen zu werden:",
RoomId_tips:"Raum-ID abrufen: ",
copyRoomId_tips:"Raum-ID kopieren",
url_tips:"Abrufen über URL: ",
copyUrl_tips:"Freigabe-URL kopieren",
scanQR_tips:"Scannen Sie den QR-Code, um zu empfangen 👇",
Copied_dis:"Kopiert",
Copy_QR_dis:"QR-Code kopieren",
download_QR_dis:"QR-Code herunterladen",
},
ClipboardApp:{
fetchRoom_err:"Fehler beim Abrufen eines Raums. Bitte versuchen Sie es erneut.",
roomCheck:{//handleShareRoomCheck
empty_msg:"Raum-ID darf nicht leer sein",
available_msg: 'Raum ist verfügbar',
notAvailable_msg: 'Raum ist nicht verfügbar, bitte versuchen Sie einen anderen',
},
channelOpen_msg:"'Datenkanal ist geöffnet, bereit zum Empfangen von Daten...'",
waitting_tips:"Warten auf den Empfänger, der sich verbindet. Bitte lassen Sie diese Seite geöffnet, bis die Übertragung abgeschlossen ist. Auf dem Desktop können Sie den Browser minimieren oder zwischen Tabs wechseln. Auf mobilen Geräten sollte der Browser im Vordergrund bleiben.",
joinRoom: {
EmptyMsg: "Warnung, die Raum-ID ist leer",
DuplicateMsg: "Die eingegebene Raum-ID ist doppelt. Bitte geben Sie sie erneut ein.",
successMsg: "Raum erfolgreich betreten! Schließen Sie diese Seite nicht, bis die Übertragung abgeschlossen ist. (Am Desktop können Sie den Browser minimieren oder Tabs wechseln; auf mobilen Geräten bringen Sie den Browser nicht in den Hintergrund.)",
notExist: "Der Raum, dem Sie beitreten möchten, existiert nicht. Nur der Sender kann einen Raum erstellen.",
failMsg: "Fehler beim Beitreten zum Raum:"
},
pickSaveMsg: "Direkt auf Festplatte speichern?",
roomStatus: {
senderEmptyMsg: "Raum ist leer",
receiverEmptyMsg: "Sie können eine Einladung annehmen, um dem Raum beizutreten",
onlyOneMsg: "Sie sind der Einzige hier",
peopleMsg_template: "{peerCount} Personen im Raum",
connected_dis:"Verbunden",
},
html:{//html 部分的消息
senderTab: "Senden",
retrieveTab: "Abrufen",
shareTitle_dis:"Inhalte teilen",
retrieveTitle_dis:"Inhalte abrufen",
RoomStatus_dis:"Status:",
Paste_dis:"Einfügen",
Copy_dis:"Kopieren",
inputRoomIdprompt: "Ihre Raum-ID (bearbeitbar):",
joinRoomBtn: "Raum beitreten",
startSendingBtn: "Senden starten",
readClipboardToRoomId: "Raum-ID einfügen",
enterRoomID_placeholder: "Raum-ID eingeben",
retrieveMethod: "Abrufmethode",
inputRoomId_tips:"Ihre Raum-ID (bearbeitbar):",
joinRoom_dis:"Raum beitreten",
startSending_loadingText:"Gesendet",
startSending_dis:"Senden starten",
readClipboard_dis:"Raum-ID einfügen",
retrieveRoomId_placeholder:"Raum-ID eingeben",
RetrieveMethodTitle:"Abrufmethode",
}
},
home: {
h1: "Kostenloses sicheres Online-Clipboard & Dateiübertragungstool",
h1P: "Teilen Sie mühelos Text, Bilder, Dateien und Ordner mit beispielloser Privatsphäre, komplett kostenlos und ohne Registrierung. Keine Einschränkungen bei Größe oder Geschwindigkeit. Genießen Sie sichere, Ende-zu-Ende-verschlüsselte Übertragungen direkt zwischen Geräten ohne Kosten.",
h2_screenOnly: 'Jetzt sicheres Clipboard & Dateiübertragungstool ausprobieren',
h2_demo: "Sichere Dateifreigabe in Aktion sehen",
h2P_demo: "Sehen Sie, wie unsere lokale, Ende-zu-Ende-verschlüsselte Dateifreigabe Ihre Privatsphäre schützt",
watch_tips:"Sie können das Video auch auf diesen Plattformen ansehen:",
youtube_tips: "SecureShare auf YouTube ansehen",
bilibili_tips:"SecureShare auf Bilibili ansehen",
}
},
}
+265
View File
@@ -0,0 +1,265 @@
import { Messages } from '@/types/messages'
export const en: Messages = {
meta: {
home: {
title: "SecureShare: Free P2P File & Clipboard Sharing | Private & No Upload",
description: "SecureShare enables instant, secure P2P file sharing without size limits or registration. Share text, images, folders across devices with end-to-end encryption. Perfect for team collaboration and private file transfer.",
keywords: 'file sharing, secure file transfer, P2P file transfer, webrtc file sharing, private clipboard, team collaboration, cross-device sharing, encrypted file transfer, no-registration file sharing, unlimited file transfer, folder sync, mobile file transfer, secure messaging, instant file sharing, private data transfer',
},
about: {
title: "About SecureShare",
description: "Learn about SecureShare, our mission to provide a secure and private file transfer and clipboard sharing service, and our commitment to user privacy and data protection."
},
faq:{
title: "SecureShare FAQ",
description: "Find answers to frequently asked questions about SecureShare, including how to send files, share clipboard content, and ensure secure and private data transfers.",
keywords: 'SecureShare FAQ,frequently asked questions,secure file sharing FAQ,private data sharing help,end-to-end encrypted file transfer,secure clipboard sharing support,how to use SecureShare,file transfer FAQ,privacy-focused sharing questions,SecureShare troubleshooting',
},
help: {
title: "SecureShare Help and Support",
description: "Find information on how to contact SecureShare support, as well as links to our About, Terms of Use, and Privacy Policy pages for more details about our service."
},
privacy: {
title: "SecureShare Privacy Policy",
description: "Understand how SecureShare protects your privacy and data, including details on information collection, data storage and security, and our commitment to not sharing your data with third parties."
},
terms: {
title: "SecureShare Terms of Use",
description: "Review the terms of use for SecureShare, including information about the acceptable use of the service, data privacy and security, and limitations of liability."
},
},
text: {
Header:{
Home_dis:"Home",
Blog_dis:"blog",
About_dis:"About",
Help_dis:"Help",
FAQ_dis:"FAQ",
Terms_dis:"Terms",
Privacy_dis:"Privacy",
},
Footer:{
CopyrightNotice:"SecureShare. All rights reserved.",
Terms_dis:"Terms of Use",
Privacy_dis:"Privacy Policy",
SupportedLanguages: "Supported Languages"
},
privacy:{
PrivacyPolicy_dis:"Privacy Policy",
h1:"SecureShare Privacy Policy",
h1_P:"At SecureShare, we are committed to protecting your privacy and safeguarding your personal information. This privacy policy outlines how we collect, use, and protect the data you provide while using our service.",
h2_1:"Information Collection",
h2_1_P:"SecureShare does not collect any personally identifiable information from users. We do not require registration or account creation to use our service. The only information we collect is the Room ID and the file/clipboard data you choose to share with other users.",
h2_2:"Data Storage and Security",
h2_2_P:"We do not store any of your data on our servers. All file transfers and clipboard sharing are handled using end-to-end encryption, ensuring that your information remains secure and accessible only to the intended recipient. Once the transfer is complete, the data is removed from our systems.",
h2_3:"Third-Party Services",
h2_3_P:"SecureShare does not integrate with any third-party services or platforms. We do not share or sell your data to any third parties.",
h2_4:"Amendments to the Privacy Policy",
h2_4_P:"We may update this privacy policy from time to time to reflect changes in our practices or applicable laws. Any changes will be effective immediately upon posting the updated policy on our website. It is your responsibility to review the privacy policy periodically for any updates.",
h2_5:"Contact Us",
h2_5_P:"If you have any questions or concerns about our privacy practices, please feel free to contact us at",
},
terms:{
TermsOfUse_dis:"Terms of Use",
h1:"SecureShare Terms of Use",
h1_P:"By using the SecureShare service, you agree to be bound by these terms of use. If you do not agree to these terms, please do not use the service.",
h2_1:"Use of the Service",
h2_1_P:"SecureShare is provided as a free service without any restrictions.",
h2_2:"Data Privacy and Security",
h2_2_P:"We take the privacy and security of your data very seriously. All file transfers and clipboard sharing are secured with end-to-end encryption, and we do not store any of your data on our servers. However, we cannot guarantee the security of your data during the transfer process, and you use the service at your own risk.",
h2_3:"Acceptable Use",
h2_3_P:"You agree not to use SecureShare for any unlawful, abusive, or harmful purpose. This includes, but is not limited to, the transfer of illegal, copyrighted, or malicious content, as well as the use of the service to harass or impersonate others.",
h2_4:"Limitation of Liability",
h2_4_P:"SecureShare is provided \"as is\" without any warranties or guarantees. We shall not be liable for any direct, indirect, or consequential damages arising from the use of our service, including but not limited to data loss, system failures, or interruptions in service.",
h2_5:"Changes to the Terms of Use",
h2_5_P:"We reserve the right to update these terms of use at any time. Any changes will be effective immediately upon posting the updated terms on our website. It is your responsibility to review the terms of use periodically for any changes.",
},
help:{
Help_dis:"Help",
h1:"SecureShare Help and Support",
h1_P:"We're here to help you make the most out of SecureShare. If you have any questions or need assistance, please don't hesitate to reach out to us.",
h2_1:"Contact Us",
h2_1_P1:"You can send us an email at",
h2_1_P2:". We will get back to you within 24 hours.",
h2_2:"Social Media",
h2_2_P:"You can also find us on social media:",
h2_3:"Additional Resources",
h2_3_P:"For more information about SecureShare, please check out the following pages:",
},
about:{
h1:"About SecureShare",
P1:"SecureShare is a free and secure file transfer and clipboard sharing tool designed with privacy and ease-of-use in mind. Our mission is to provide a simple, yet powerful solution for transferring files and sharing content across devices without any restrictions.",
P2:"At the core of SecureShare is our commitment to security and privacy. We use end-to-end encryption to ensure that your data is protected during the transfer process, and we never store your files or clipboard content on our servers. This means that your data stays local and under your control.",
P3:"With SecureShare, you can effortlessly share text, images, and files of any size without the need for registration or logins. Our platform is designed to be fast, efficient, and environmentally friendly, with a focus on providing a seamless and user-friendly experience.",
P4:"We believe in empowering users to take control of their digital lives, and SecureShare is our contribution to that vision. We hope that our tool will help you securely share and collaborate with your friends, family, and colleagues, without compromising your privacy or security.",
P5:"For more information or questions, please visit the following pages:"
},
HowItWorks:{
h2: "How it works",
h2_P: "Share files and messages instantly in three simple steps",
btn_try: "Try it now →",
step1_title:"Type or Choose Files",
step1_description:"Type your message or drag & drop files/folders into the selection area",
step2_title:"Join Room",
step2_description:"Click the 'Join Room' button to create a sharing session",
step3_title:"Receive",
step3_description:"Enter the Room ID on the receive page and click 'Join Room' to get the shared content",
},
SystemDiagram:{
h2: "System diagram",
h2_P: "SecureShare: Your data, your control. Simple, fast, and private.",
},
KeyFeatures:{
h2: "Key Features",
h3_1: "Direct and Secure",
h3_1_P: "Your files travel straight from your device to the recipient's, like a secret tunnel only you two can access. With end-to-end encryption, it's like your data is speaking a language only the intended recipient can understand. Don't want to share anymore? Simply close your browser tab, and it's like hanging up a phone call - you're in control.",
h3_2: "Team Synergy",
h3_2_P: "Share with your entire team as easily as sharing with one person. Like hosting a digital roundtable, everyone gets the files simultaneously. Whether you're collaborating on a creative project or distributing important documents, it's like having everyone in the same room, receiving your shared vision at once. Perfect for brainstorming sessions, team presentations, or any moment when multiple minds need to connect.",
h3_3: "No Limits, Smart Handling",
h3_3_P: "Imagine a magical pipeline that can transport anything, no matter how big! Send files of any size, limited only by your disk space. For those extra-large files, choose where to save them on your device. It's like having a special delivery service that doesn't slow down your computer - files go straight to disk, keeping your device speedy and responsive.",
h3_4: "Swift as a Thought",
h3_4_P: "Share text, images, and even entire folders as quickly as you can think of them. It's like teleporting your digital stuff instantly. Need to send a whole photo album or a folder full of documents? No problem! It's as easy as sharing a single file.",
h3_5: "Green and Clean",
h3_5_P: "We're like a digital version of a face-to-face conversation - nothing gets stored anywhere else. This means we're super environmentally friendly, using minimal resources. It's like leaving no footprint in the digital world, keeping things clean and green for everyone.",
},
faqs:{
FAQ_dis:"Frequently Asked Questions",
question_0: "Is the data truly stored locally and not transferred to other servers?",
answer_0: "Yes, all data is handled locally. You can check the YouTube video on our homepage—files can still be transferred within a local network even if the internet is disconnected after establishing a connection. In the future, we plan to open source the code so everyone can review it.",
question_1: "How do I send and receive folders?",
answer_1: "Sending a folder is as simple as sending a file. Drag the folder into the file selection area or click the area to select it, then hit \"Start Sending.\" button On the receiving end, users can download directly or choose a save directory before downloading. The former saves to memory, while the latter saves directly to disk.",
question_2: "Can I change the Room ID?",
answer_2: "Yes, you can change the Room ID to any string you prefer.",
question_3: "Can I share content continuously?",
answer_3: "As long as you remain connected, you can manually click the \"Start Sending\" button to update the shared content whenever it changes.",
question_4: "Can I share files with multiple recipients simultaneously?",
answer_4: "Of course! There's no difference between one person receiving and multiple people receiving simultaneously.",
question_5: "Is my data secure when using SecureShare?",
answer_5: "Absolutely secure. Your data always stays local, transferring between devices through an encrypted, end-to-end connection. All transmitted data is encrypted, ensuring only you and the recipient can access it.",
question_6: "Do I need to create an account to use SecureShare?",
answer_6: "No registration or login required—just open the site and start using it. Convenience and speed are our priorities.",
question_7: "Are there any file size limits?",
answer_7: "No limits on file size or speed. As long as you have enough disk space, you can transfer files of any size by setting a save directory before downloading.",
question_8: "Can I share folders or multiple files at once?",
answer_8: "Yes, sharing multiple files or folders is as simple as sharing a single file. You can also add files to the transfer—just click \"Start Sending\" to update them for the recipient.",
question_9: "How do I stop sharing if I change my mind?",
answer_9: "Stopping a share is as simple as closing the browser tab or window. Once you do this, the connection is terminated, and no further data can be transferred.",
question_10: "Does using SecureShare slow down my device?",
answer_10: "No, SecureShare is designed to be lightweight and efficient. If you set a save directory, all received data is written directly to disk, bypassing memory, which helps maintain your device's performance.",
question_11: "Can I use SecureShare offline?",
answer_11: "Yes, if the sender and receiver are on the same local network, they can join the same room while connected to the internet and then disconnect from it. File sharing will still work. You can refer to the YouTube video on the homepage for details.",
question_12: "Does SecureShare use any servers?",
answer_12: "Yes, there is indeed a lightweight server, which is used only for signaling to establish an encrypted connection. Once the connection is established, all data is transferred directly between devices through the encrypted connection.",
question_13: "What is the expiration period for Room IDs?",
answer_13: "The initial validity of a RoomId is 24 hours. If a recipient joins the room, the validity is automatically extended by 24 hours from that moment.",
},
clipboard_btn:{
Pasted_dis:"Pasted",
Copied_dis:"Copied",
},
fileUploadHandler:{
NoFileChosen_tips:"No file chosen",
fileChosen_tips_template: "{fileNum} file(s) and {folderNum} folder(s) selected",
Drag_tips:"Drag and drop files/folders here or click to choose",
chosenDiagTitle:"Choose Upload Type",
chosenDiagDescription:"Select whether you want to upload files or a folder",
SelectFile_dis:"Select Files",
SelectFolder_dis:"Select Folder",
},
FileTransferButton:{
SavedToDisk_tips:"File already saved to disk",
CurrentFileTransferring_tips:"File is being transferred",
OtherFileTransferring_tips:"Please wait for current transfer to complete",
download_tips:"Click to download file",
Saved_dis:"Saved",
Waiting_dis:"Waiting",
Download_dis:"Download",
},
FileListDisplay:{
sending_dis: 'Sending',
receiving_dis: 'Receiving',
finish_dis:"finished",
delete_dis:"Delete",
downloadNum_dis:"Download count",
folder_tips_template:"folder name:{name} ({num} files and {size}) in total",
folder_dis_template:" ({num} files, {size})",
PopupDialog_title:"Recommended: Choose a Save Directory",
PopupDialog_description:"We recommend selecting a save directory to directly save files to your disk. This makes it easier to transfer large files and synchronize folders efficiently.",
chooseSavePath_tips:"Save large files or folders directly to a selected directory. 👉",
chooseSavePath_dis:"Choose save location",
},
RetrieveMethod:{
P:"Congrats 🎉 Share content is waiting to be retrieved:",
RoomId_tips:"Retrieve RoomID: ",
copyRoomId_tips:"Copy RoomID",
url_tips:"Retrieve using URL: ",
copyUrl_tips:"Copy share url",
scanQR_tips:"Scan the QR code to receive 👇",
Copied_dis:"Copied",
Copy_QR_dis:"Copy QR code",
download_QR_dis:"Download QR code",
},
ClipboardApp:{
fetchRoom_err:"Failed to get a room. Please try again.",
roomCheck:{//handleShareRoomCheck
empty_msg:"RoomID should not be empty",
available_msg: 'Room is available',
notAvailable_msg: 'Room is not available, please try another',
},
channelOpen_msg:"'data channel is opened,ready to receive data...'",
waitting_tips:"Waiting for receiver to connect. Please keep this page open until the transfer is complete. On desktop, you can minimize the browser or switch tabs. On mobile, please keep the browser in the foreground.",
joinRoom: {
EmptyMsg: "Warning, the roomID is empty",
DuplicateMsg: "The room ID you entered is duplicate. Please re-enter.",
successMsg: "Successfully joined the room! Do not close this page until the transfer is complete. (On desktop, you can minimize the browser or switch tabs; on mobile, do not move the browser to the background.)",
notExist: "The room you are trying to join does not exist. Only the sender can create a room.",
failMsg: "Failed to join room:"
},
pickSaveMsg: "Save Directly to Disk ?",
roomStatus: {
senderEmptyMsg: "Room is empty",
receiverEmptyMsg: "You can accept an invitation to join the room",
onlyOneMsg: "Youre the only one here",
peopleMsg_template: "{peerCount} Peoples in the room",
connected_dis:"Connected",
},
html:{//html 部分的消息
senderTab: "Send",
retrieveTab: "Retrieve",
shareTitle_dis:"Share Content",
retrieveTitle_dis:"Retrieve Content",
RoomStatus_dis:"Status:",
Paste_dis:"Paste",
Copy_dis:"Copy",
inputRoomIdprompt: "Your RoomID (Editable):",
joinRoomBtn: "Join room",
startSendingBtn: "Start sending",
readClipboardToRoomId: "Paste RoomID",
enterRoomID_placeholder: "enter RoomID",
retrieveMethod: "Retrieve method",
inputRoomId_tips:"Your RoomID (Editable):",
joinRoom_dis:"Join room",
startSending_loadingText:"Sended",
startSending_dis:"Start sending",
readClipboard_dis:"Paste RoomID",
retrieveRoomId_placeholder:"Enter RoomID",
RetrieveMethodTitle:"Retrieve method",
}
},
home: {
h1: "Free Secure Online Clipboard & File Transfer Tool",
h1P: "Effortlessly share text, images, files, and folders with unparalleled privacy, completely free and with no registration needed. No restrictions on size or speed. Enjoy secure, end-to-end encrypted transfers directly between devices at zero cost.",
h2_screenOnly: 'Try Secure Clipboard & File Transfer Tool Now',
h2_demo: "See Secure File Sharing in Action",
h2P_demo: "Watch how our local-first, end-to-end encrypted file sharing protects your privacy",
watch_tips:"You can also watch the video on these platforms:",
youtube_tips: "Watch SecureShare on YouTube",
bilibili_tips:"Watch SecureShare on Bilibili",
}
},
}
+261
View File
@@ -0,0 +1,261 @@
import { Messages } from '@/types/messages'
export const es: Messages = {
meta: {
home: {
title: "SecureShare: Compartición Gratuita de Archivos y Portapapeles P2P | Privado y Sin Subidas",
description: "SecureShare permite compartir archivos P2P de forma instantánea y segura sin límites de tamaño ni registro. Comparta texto, imágenes y carpetas entre dispositivos con cifrado de extremo a extremo. Perfecto para colaboración en equipo y transferencia privada de archivos.",
keywords: 'compartir archivos, transferencia segura de archivos, transferencia P2P, compartir archivos webrtc, portapapeles privado, colaboración en equipo, compartir entre dispositivos, transferencia cifrada, compartir sin registro, transferencia ilimitada, sincronización de carpetas, transferencia móvil, mensajería segura, compartir instantáneo, transferencia privada de datos',
},
about: {
title: "Acerca de SecureShare",
description: "Conozca SecureShare, nuestra misión de proporcionar un servicio seguro y privado de transferencia de archivos y compartición de portapapeles, y nuestro compromiso con la privacidad y protección de datos."
},
faq: {
title: "Preguntas Frecuentes de SecureShare",
description: "Encuentre respuestas a preguntas frecuentes sobre SecureShare, incluyendo cómo enviar archivos, compartir contenido del portapapeles y asegurar transferencias seguras y privadas.",
keywords: 'FAQ SecureShare,preguntas frecuentes,FAQ compartir archivos seguros,ayuda compartir datos privados,transferencia cifrada extremo a extremo,soporte compartir portapapeles seguro,cómo usar SecureShare,FAQ transferencia archivos,preguntas compartir privado,solución problemas SecureShare',
},
help: {
title: "Ayuda y Soporte de SecureShare",
description: "Encuentre información sobre cómo contactar con el soporte de SecureShare, así como enlaces a nuestras páginas Acerca de, Términos de Uso y Política de Privacidad para más detalles sobre nuestro servicio."
},
privacy: {
title: "Política de Privacidad de SecureShare",
description: "Comprenda cómo SecureShare protege su privacidad y datos, incluyendo detalles sobre recopilación de información, almacenamiento y seguridad de datos, y nuestro compromiso de no compartir sus datos con terceros."
},
terms: {
title: "Términos de Uso de SecureShare",
description: "Revise los términos de uso de SecureShare, incluyendo información sobre el uso aceptable del servicio, privacidad y seguridad de datos, y limitaciones de responsabilidad."
}
},
text: {
Header: {
Home_dis: "Inicio",
Blog_dis: "Blog",
About_dis: "Acerca de",
Help_dis: "Ayuda",
FAQ_dis: "FAQ",
Terms_dis: "Términos",
Privacy_dis: "Privacidad",
},
Footer: {
CopyrightNotice: "SecureShare. Todos los derechos reservados.",
Terms_dis: "Términos de Uso",
Privacy_dis: "Política de Privacidad",
SupportedLanguages: "Idiomas soportados"
},
privacy: {
PrivacyPolicy_dis: "Política de Privacidad",
h1: "Política de Privacidad de SecureShare",
h1_P: "En SecureShare, estamos comprometidos con proteger su privacidad y salvaguardar su información personal. Esta política de privacidad describe cómo recopilamos, usamos y protegemos los datos que proporciona al usar nuestro servicio.",
h2_1: "Recopilación de Información",
h2_1_P: "SecureShare no recopila ninguna información personal identificable de los usuarios. No requerimos registro ni creación de cuenta para usar nuestro servicio. La única información que recopilamos es el ID de Sala y los datos de archivo/portapapeles que elige compartir con otros usuarios.",
h2_2: "Almacenamiento y Seguridad de Datos",
h2_2_P: "No almacenamos ninguno de sus datos en nuestros servidores. Todas las transferencias de archivos y compartición de portapapeles se manejan usando cifrado de extremo a extremo, asegurando que su información permanezca segura y accesible solo para el destinatario previsto. Una vez completada la transferencia, los datos se eliminan de nuestros sistemas.",
h2_3: "Servicios de Terceros",
h2_3_P: "SecureShare no se integra con ningún servicio o plataforma de terceros. No compartimos ni vendemos sus datos a terceros.",
h2_4: "Modificaciones a la Política de Privacidad",
h2_4_P: "Podemos actualizar esta política de privacidad ocasionalmente para reflejar cambios en nuestras prácticas o leyes aplicables. Cualquier cambio será efectivo inmediatamente al publicar la política actualizada en nuestro sitio web. Es su responsabilidad revisar la política de privacidad periódicamente para cualquier actualización.",
h2_5: "Contáctenos",
h2_5_P: "Si tiene alguna pregunta o inquietud sobre nuestras prácticas de privacidad, no dude en contactarnos en",
},
terms: {
TermsOfUse_dis: "Términos de Uso",
h1: "Términos de Uso de SecureShare",
h1_P: "Al usar el servicio SecureShare, acepta estar sujeto a estos términos de uso. Si no está de acuerdo con estos términos, por favor no use el servicio.",
h2_1: "Uso del Servicio",
h2_1_P: "SecureShare se proporciona como un servicio gratuito sin restricciones.",
h2_2: "Privacidad y Seguridad de Datos",
h2_2_P: "Tomamos muy en serio la privacidad y seguridad de sus datos. Todas las transferencias de archivos y compartición de portapapeles están aseguradas con cifrado de extremo a extremo, y no almacenamos ninguno de sus datos en nuestros servidores. Sin embargo, no podemos garantizar la seguridad de sus datos durante el proceso de transferencia, y usa el servicio bajo su propio riesgo.",
h2_3: "Uso Aceptable",
h2_3_P: "Acepta no usar SecureShare para ningún propósito ilegal, abusivo o dañino. Esto incluye, pero no se limita a, la transferencia de contenido ilegal, con derechos de autor o malicioso, así como el uso del servicio para acosar o suplantar a otros.",
h2_4: "Limitación de Responsabilidad",
h2_4_P: "SecureShare se proporciona \"tal cual\" sin ninguna garantía. No seremos responsables por ningún daño directo, indirecto o consecuente que surja del uso de nuestro servicio, incluyendo pero no limitado a pérdida de datos, fallos del sistema o interrupciones del servicio.",
h2_5: "Cambios en los Términos de Uso",
h2_5_P: "Nos reservamos el derecho de actualizar estos términos de uso en cualquier momento. Cualquier cambio será efectivo inmediatamente al publicar los términos actualizados en nuestro sitio web. Es su responsabilidad revisar los términos de uso periódicamente para cualquier cambio.",
},
help: {
Help_dis: "Ayuda",
h1: "Ayuda y Soporte de SecureShare",
h1_P: "Estamos aquí para ayudarte a aprovechar al máximo SecureShare. Si tienes alguna pregunta o necesitas asistencia, no dudes en contactarnos.",
h2_1: "Contáctanos",
h2_1_P1: "Puedes enviarnos un correo electrónico a",
h2_1_P2: ". Te responderemos dentro de 24 horas.",
h2_2: "Redes Sociales",
h2_2_P: "También puedes encontrarnos en redes sociales:",
h2_3: "Recursos Adicionales",
h2_3_P: "Para más información sobre SecureShare, por favor consulta las siguientes páginas:",
},
about: {
h1: "Acerca de SecureShare",
P1: "SecureShare es una herramienta gratuita y segura de transferencia de archivos y compartición de portapapeles diseñada pensando en la privacidad y facilidad de uso. Nuestra misión es proporcionar una solución simple pero potente para transferir archivos y compartir contenido entre dispositivos sin restricciones.",
P2: "En el núcleo de SecureShare está nuestro compromiso con la seguridad y privacidad. Usamos cifrado de extremo a extremo para asegurar que sus datos estén protegidos durante el proceso de transferencia, y nunca almacenamos sus archivos o contenido del portapapeles en nuestros servidores. Esto significa que sus datos permanecen locales y bajo su control.",
P3: "Con SecureShare, puede compartir sin esfuerzo texto, imágenes y archivos de cualquier tamaño sin necesidad de registro o inicio de sesión. Nuestra plataforma está diseñada para ser rápida, eficiente y amigable con el medio ambiente, con un enfoque en proporcionar una experiencia fluida y fácil de usar.",
P4: "Creemos en empoderar a los usuarios para tomar control de sus vidas digitales, y SecureShare es nuestra contribución a esa visión. Esperamos que nuestra herramienta le ayude a compartir y colaborar de forma segura con sus amigos, familia y colegas, sin comprometer su privacidad o seguridad.",
P5: "Para más información o preguntas, por favor visite las siguientes páginas:"
},
HowItWorks: {
h2: "Cómo funciona",
h2_P: "Comparte archivos y mensajes instantáneamente en tres simples pasos",
btn_try: "Pruébalo ahora →",
step1_title: "Escribe o Elige Archivos",
step1_description: "Escribe tu mensaje o arrastra y suelta archivos/carpetas en el área de selección",
step2_title: "Únete a la Sala",
step2_description: "Haz clic en el botón 'Unirse a Sala' para crear una sesión de compartición",
step3_title: "Recibe",
step3_description: "Ingresa el ID de Sala en la página de recepción y haz clic en 'Unirse a Sala' para obtener el contenido compartido",
},
SystemDiagram: {
h2: "Diagrama del sistema",
h2_P: "SecureShare: Tus datos, tu control. Simple, rápido y privado.",
},
KeyFeatures: {
h2: "Características Principales",
h3_1: "Directo y Seguro",
h3_1_P: "Tus archivos viajan directamente desde tu dispositivo al del destinatario, como un túnel secreto que solo ustedes dos pueden acceder. Con cifrado de extremo a extremo, es como si tus datos hablaran un idioma que solo el destinatario previsto puede entender. ¿No quieres compartir más? Simplemente cierra la pestaña del navegador, y es como colgar una llamada telefónica - tú tienes el control.",
h3_2: "Sinergia de Equipo",
h3_2_P: "Comparte con todo tu equipo tan fácilmente como compartir con una persona. Como organizar una mesa redonda digital, todos reciben los archivos simultáneamente. Ya sea que estés colaborando en un proyecto creativo o distribuyendo documentos importantes, es como tener a todos en la misma sala, recibiendo tu visión compartida al mismo tiempo. Perfecto para sesiones de lluvia de ideas, presentaciones de equipo o cualquier momento en que múltiples mentes necesiten conectarse.",
h3_3: "Sin Límites, Manejo Inteligente",
h3_3_P: "¡Imagina una tubería mágica que puede transportar cualquier cosa, sin importar qué tan grande! Envía archivos de cualquier tamaño, limitado solo por tu espacio en disco. Para esos archivos extra grandes, elige dónde guardarlos en tu dispositivo. Es como tener un servicio de entrega especial que no ralentiza tu computadora - los archivos van directamente al disco, manteniendo tu dispositivo rápido y receptivo.",
h3_4: "Rápido como un Pensamiento",
h3_4_P: "Comparte texto, imágenes e incluso carpetas enteras tan rápido como puedas pensarlos. Es como teletransportar tus cosas digitales instantáneamente. ¿Necesitas enviar un álbum de fotos completo o una carpeta llena de documentos? ¡No hay problema! Es tan fácil como compartir un solo archivo.",
h3_5: "Verde y Limpio",
h3_5_P: "Somos como una versión digital de una conversación cara a cara - nada se almacena en ningún otro lugar. Esto significa que somos super amigables con el medio ambiente, usando recursos mínimos. Es como no dejar huella en el mundo digital, manteniendo las cosas limpias y verdes para todos.",
},
faqs: {
FAQ_dis: "Preguntas Frecuentes",
question_0: "¿Los datos realmente se almacenan localmente y no se transfieren a otros servidores?",
answer_0: "Sí, todos los datos se manejan localmente. Puedes verificar el video de YouTube en nuestra página de inicio: los archivos aún se pueden transferir dentro de una red local incluso si se desconecta internet después de establecer una conexión. En el futuro, planeamos hacer el código de código abierto para que todos puedan revisarlo.",
question_1: "¿Cómo envío y recibo carpetas?",
answer_1: "Enviar una carpeta es tan simple como enviar un archivo. Arrastra la carpeta al área de selección de archivos o haz clic en el área para seleccionarla, luego presiona el botón \"Comenzar a Enviar\". En el lado receptor, los usuarios pueden descargar directamente o elegir un directorio de guardado antes de descargar. El primero guarda en memoria, mientras que el último guarda directamente en disco.",
question_2: "¿Puedo cambiar el ID de Sala?",
answer_2: "Sí, puedes cambiar el ID de Sala a cualquier cadena que prefieras.",
question_3: "¿Puedo compartir contenido continuamente?",
answer_3: "Mientras permanezcas conectado, puedes hacer clic manualmente en el botón \"Comenzar a Enviar\" para actualizar el contenido compartido cuando cambie.",
question_4: "¿Puedo compartir archivos con múltiples destinatarios simultáneamente?",
answer_4: "¡Por supuesto! No hay diferencia entre que una persona reciba y que múltiples personas reciban simultáneamente.",
question_5: "¿Mis datos están seguros al usar SecureShare?",
answer_5: "Absolutamente seguros. Tus datos siempre permanecen locales, transfiriéndose entre dispositivos a través de una conexión cifrada de extremo a extremo. Todos los datos transmitidos están cifrados, asegurando que solo tú y el destinatario puedan acceder a ellos.",
question_6: "¿Necesito crear una cuenta para usar SecureShare?",
answer_6: "No se requiere registro ni inicio de sesión, solo abre el sitio y comienza a usarlo. La conveniencia y la velocidad son nuestras prioridades.",
question_7: "¿Hay algún límite de tamaño de archivo?",
answer_7: "No hay límites en el tamaño o velocidad del archivo. Mientras tengas suficiente espacio en disco, puedes transferir archivos de cualquier tamaño estableciendo un directorio de guardado antes de descargar.",
question_8: "¿Puedo compartir carpetas o múltiples archivos a la vez?",
answer_8: "Sí, compartir múltiples archivos o carpetas es tan simple como compartir un solo archivo. También puedes agregar archivos a la transferencia: solo haz clic en \"Comenzar a Enviar\" para actualizarlos para el destinatario.",
question_9: "¿Cómo dejo de compartir si cambio de opinión?",
answer_9: "Detener una compartición es tan simple como cerrar la pestaña o ventana del navegador. Una vez que hagas esto, la conexión se termina y no se pueden transferir más datos.",
question_10: "¿Usar SecureShare ralentiza mi dispositivo?",
answer_10: "No, SecureShare está diseñado para ser ligero y eficiente. Si estableces un directorio de guardado, todos los datos recibidos se escriben directamente en el disco, evitando la memoria, lo que ayuda a mantener el rendimiento de tu dispositivo.",
question_11: "¿Puedo usar SecureShare sin conexión?",
answer_11: "Sí, si el remitente y el receptor están en la misma red local, pueden unirse a la misma sala mientras están conectados a internet y luego desconectarse. La compartición de archivos seguirá funcionando. Puedes consultar el video de YouTube en la página de inicio para más detalles.",
question_12: "¿SecureShare usa algún servidor?",
answer_12: "Sí, hay un servidor ligero que se usa solo para señalización para establecer una conexión cifrada. Una vez que se establece la conexión, todos los datos se transfieren directamente entre dispositivos a través de la conexión cifrada.",
question_13: "¿Cuál es el período de expiración para los ID de Sala?",
answer_13: "La validez inicial de un ID de Sala es de 24 horas. Si un destinatario se une a la sala, la validez se extiende automáticamente por 24 horas desde ese momento.",
},
clipboard_btn: {
Pasted_dis: "Pegado",
Copied_dis: "Copiado",
},
fileUploadHandler: {
NoFileChosen_tips: "Ningún archivo seleccionado",
fileChosen_tips_template: "{fileNum} archivo(s) y {folderNum} carpeta(s) seleccionados",
Drag_tips: "Arrastra y suelta archivos/carpetas aquí o haz clic para elegir",
chosenDiagTitle: "Elegir Tipo de Carga",
chosenDiagDescription: "Selecciona si deseas cargar archivos o una carpeta",
SelectFile_dis: "Seleccionar Archivos",
SelectFolder_dis: "Seleccionar Carpeta",
},
FileTransferButton: {
SavedToDisk_tips: "Archivo ya guardado en disco",
CurrentFileTransferring_tips: "El archivo se está transfiriendo",
OtherFileTransferring_tips: "Por favor espera a que se complete la transferencia actual",
download_tips: "Haz clic para descargar el archivo",
Saved_dis: "Guardado",
Waiting_dis: "Esperando",
Download_dis: "Descargar",
},
FileListDisplay: {
sending_dis: 'Enviando',
receiving_dis: 'Recibiendo',
finish_dis: "terminado",
delete_dis: "Eliminar",
downloadNum_dis:"Número de descargas",
folder_tips_template: "nombre de carpeta:{name} ({num} archivos y {size}) en total",
folder_dis_template: " ({num} archivos, {size})",
PopupDialog_title: "Recomendado: Elige un Directorio de Guardado",
PopupDialog_description: "Recomendamos seleccionar un directorio de guardado para guardar archivos directamente en tu disco. Esto facilita la transferencia de archivos grandes y la sincronización de carpetas de manera eficiente.",
chooseSavePath_tips: "Guarda archivos grandes o carpetas directamente en un directorio seleccionado. 👉",
chooseSavePath_dis: "Elegir ubicación de guardado",
},
RetrieveMethod: {
P: "¡Felicitaciones 🎉 El contenido compartido está esperando ser recuperado:",
RoomId_tips: "ID de Sala para recuperar: ",
copyRoomId_tips: "Copiar ID de Sala",
url_tips: "Recuperar usando URL: ",
copyUrl_tips: "Copiar URL de compartición",
scanQR_tips: "Escanea el código QR para recibir 👇",
Copied_dis: "Copiado",
Copy_QR_dis: "Copiar código QR",
download_QR_dis: "Descargar código QR",
},
ClipboardApp: {
fetchRoom_err: "Error al obtener una sala. Por favor intenta de nuevo.",
roomCheck: {
empty_msg: "El ID de Sala no debe estar vacío",
available_msg: 'La sala está disponible',
notAvailable_msg: 'La sala no está disponible, por favor intenta otra',
},
channelOpen_msg: "'canal de datos abierto, listo para recibir datos...'",
waitting_tips: "Esperando que el receptor se conecte. Por favor mantén esta página abierta hasta que se complete la transferencia. En escritorio, puedes minimizar el navegador o cambiar pestañas. En móvil, por favor mantén el navegador en primer plano.",
joinRoom: {
EmptyMsg: "Advertencia, el ID de sala está vacío",
DuplicateMsg: "El ID de sala que ingresaste está duplicado. Por favor, vuelve a ingresar.",
successMsg: "¡Ingreso exitoso al cuarto! No cierres esta página hasta que se complete la transferencia. (En escritorio, puedes minimizar el navegador o cambiar de pestaña; en móvil, no lleves el navegador al fondo.)",
notExist: "La sala a la que intentas unirte no existe. Solo el remitente puede crear una sala.",
failMsg: "Error al unirse a la sala:"
},
pickSaveMsg: "¿Guardar Directamente en Disco?",
roomStatus: {
senderEmptyMsg: "La sala está vacía",
receiverEmptyMsg: "Puedes aceptar una invitación para unirte a la sala",
onlyOneMsg: "Eres el único aquí",
peopleMsg_template: "{peerCount} Personas en la sala",
connected_dis: "Conectado",
},
html: {
senderTab: "Enviar",
retrieveTab: "Recuperar",
shareTitle_dis: "Compartir Contenido",
retrieveTitle_dis: "Recuperar Contenido",
RoomStatus_dis: "Estado:",
Paste_dis: "Pegar",
Copy_dis: "Copiar",
inputRoomIdprompt: "Tu ID de Sala (Editable):",
joinRoomBtn: "Unirse a sala",
startSendingBtn: "Comenzar a enviar",
readClipboardToRoomId: "Pegar ID de Sala",
enterRoomID_placeholder: "ingresa ID de Sala",
retrieveMethod: "Método de recuperación",
inputRoomId_tips: "Tu ID de Sala (Editable):",
joinRoom_dis: "Unirse a sala",
startSending_loadingText: "Enviado",
startSending_dis: "Comenzar a enviar",
readClipboard_dis: "Pegar ID de Sala",
retrieveRoomId_placeholder: "Ingresa ID de Sala",
RetrieveMethodTitle: "Método de recuperación",
}
},
home: {
h1: "Herramienta Gratuita de Portapapeles y Transferencia de Archivos en Línea Segura",
h1P: "Comparte sin esfuerzo texto, imágenes, archivos y carpetas con privacidad sin igual, completamente gratis y sin necesidad de registro. Sin restricciones de tamaño o velocidad. Disfruta de transferencias seguras y cifradas de extremo a extremo directamente entre dispositivos sin costo.",
h2_screenOnly: 'Prueba la Herramienta Segura de Portapapeles y Transferencia de Archivos Ahora',
h2_demo: "Ve la Compartición Segura de Archivos en Acción",
h2P_demo: "Mira cómo nuestra compartición de archivos local y cifrada de extremo a extremo protege tu privacidad",
watch_tips: "También puedes ver el video en estas plataformas:",
youtube_tips: "Ver SecureShare en YouTube",
bilibili_tips: "Ver SecureShare en Bilibili",
}
},
}
+265
View File
@@ -0,0 +1,265 @@
import { Messages } from '@/types/messages'
export const fr: Messages = {
meta: {
home: {
title: "SecureShare : Partage de fichiers et de presse-papiers P2P gratuit | Privé et sans téléchargement",
description: "SecureShare permet un partage de fichiers P2P instantané et sécurisé sans limites de taille ni inscription. Partagez du texte, des images, des dossiers entre appareils avec un chiffrement de bout en bout. Parfait pour la collaboration d'équipe et le transfert de fichiers privés.",
keywords: 'partage de fichiers, transfert de fichiers sécurisé, transfert de fichiers P2P, partage de fichiers WebRTC, presse-papiers privé, collaboration d\'équipe, partage entre appareils, transfert de fichiers chiffré, partage de fichiers sans inscription, transfert de fichiers illimité, synchronisation de dossiers, transfert de fichiers mobile, messagerie sécurisée, partage de fichiers instantané, transfert de données privé',
},
about: {
title: "À propos de SecureShare",
description: "Découvrez SecureShare, notre mission de fournir un service de transfert de fichiers et de partage de presse-papiers sécurisé et privé, ainsi que notre engagement envers la protection de la vie privée et des données des utilisateurs."
},
faq:{
title: "FAQ de SecureShare",
description: "Trouvez des réponses aux questions fréquemment posées sur SecureShare, y compris comment envoyer des fichiers, partager du contenu de presse-papiers et assurer des transferts de données sécurisés et privés.",
keywords: 'FAQ SecureShare, questions fréquemment posées, FAQ partage de fichiers sécurisé, aide au partage de données privées, transfert de fichiers chiffré de bout en bout, support de partage de presse-papiers sécurisé, comment utiliser SecureShare, FAQ transfert de fichiers, questions sur le partage axé sur la confidentialité, dépannage SecureShare',
},
help: {
title: "Aide et support de SecureShare",
description: "Trouvez des informations sur la façon de contacter le support de SecureShare, ainsi que des liens vers nos pages À propos, Conditions d'utilisation et Politique de confidentialité pour plus de détails sur notre service."
},
privacy: {
title: "Politique de confidentialité de SecureShare",
description: "Comprenez comment SecureShare protège votre vie privée et vos données, y compris des détails sur la collecte d'informations, le stockage et la sécurité des données, ainsi que notre engagement à ne pas partager vos données avec des tiers."
},
terms: {
title: "Conditions d'utilisation de SecureShare",
description: "Consultez les conditions d'utilisation de SecureShare, y compris des informations sur l'utilisation acceptable du service, la confidentialité et la sécurité des données, ainsi que les limitations de responsabilité."
},
},
text: {
Header:{
Home_dis:"Accueil",
Blog_dis: "Blog",
About_dis:"À propos",
Help_dis:"Aide",
FAQ_dis:"FAQ",
Terms_dis:"Conditions",
Privacy_dis:"Confidentialité",
},
Footer:{
CopyrightNotice:"SecureShare. Tous droits réservés.",
Terms_dis:"Conditions d'utilisation",
Privacy_dis:"Politique de confidentialité",
SupportedLanguages:"Langues prises en charge"
},
privacy:{
PrivacyPolicy_dis:"Politique de confidentialité",
h1:"Politique de confidentialité de SecureShare",
h1_P:"Chez SecureShare, nous nous engageons à protéger votre vie privée et à sécuriser vos informations personnelles. Cette politique de confidentialité décrit comment nous collectons, utilisons et protégeons les données que vous fournissez lors de l'utilisation de notre service.",
h2_1:"Collecte d'informations",
h2_1_P:"SecureShare ne collecte aucune information personnelle identifiable des utilisateurs. Nous n'exigeons pas d'inscription ou de création de compte pour utiliser notre service. Les seules informations que nous collectons sont l'ID de la salle et les données de fichiers/presse-papiers que vous choisissez de partager avec d'autres utilisateurs.",
h2_2:"Stockage et sécurité des données",
h2_2_P:"Nous ne stockons aucune de vos données sur nos serveurs. Tous les transferts de fichiers et partages de presse-papiers sont gérés avec un chiffrement de bout en bout, garantissant que vos informations restent sécurisées et accessibles uniquement par le destinataire prévu. Une fois le transfert terminé, les données sont supprimées de nos systèmes.",
h2_3:"Services tiers",
h2_3_P:"SecureShare n'intègre aucun service ou plateforme tiers. Nous ne partageons ni ne vendons vos données à des tiers.",
h2_4:"Modifications de la politique de confidentialité",
h2_4_P:"Nous pouvons mettre à jour cette politique de confidentialité de temps à autre pour refléter les changements dans nos pratiques ou les lois applicables. Toute modification sera effective immédiatement après la publication de la politique mise à jour sur notre site web. Il est de votre responsabilité de consulter périodiquement la politique de confidentialité pour toute mise à jour.",
h2_5:"Contactez-nous",
h2_5_P:"Si vous avez des questions ou des préoccupations concernant nos pratiques de confidentialité, n'hésitez pas à nous contacter à l'adresse suivante :",
},
terms:{
TermsOfUse_dis:"Conditions d'utilisation",
h1:"Conditions d'utilisation de SecureShare",
h1_P:"En utilisant le service SecureShare, vous acceptez d'être lié par ces conditions d'utilisation. Si vous n'acceptez pas ces conditions, veuillez ne pas utiliser le service.",
h2_1:"Utilisation du service",
h2_1_P:"SecureShare est fourni comme un service gratuit sans aucune restriction.",
h2_2:"Confidentialité et sécurité des données",
h2_2_P:"Nous prenons très au sérieux la confidentialité et la sécurité de vos données. Tous les transferts de fichiers et partages de presse-papiers sont sécurisés avec un chiffrement de bout en bout, et nous ne stockons aucune de vos données sur nos serveurs. Cependant, nous ne pouvons pas garantir la sécurité de vos données pendant le processus de transfert, et vous utilisez le service à vos propres risques.",
h2_3:"Utilisation acceptable",
h2_3_P:"Vous acceptez de ne pas utiliser SecureShare à des fins illégales, abusives ou nuisibles. Cela inclut, mais sans s'y limiter, le transfert de contenu illégal, protégé par le droit d'auteur ou malveillant, ainsi que l'utilisation du service pour harceler ou usurper l'identité d'autrui.",
h2_4:"Limitation de responsabilité",
h2_4_P:"SecureShare est fourni « tel quel » sans aucune garantie. Nous ne serons pas responsables des dommages directs, indirects ou consécutifs résultant de l'utilisation de notre service, y compris, mais sans s'y limiter, la perte de données, les défaillances du système ou les interruptions de service.",
h2_5:"Modifications des conditions d'utilisation",
h2_5_P:"Nous nous réservons le droit de mettre à jour ces conditions d'utilisation à tout moment. Toute modification sera effective immédiatement après la publication des conditions mises à jour sur notre site web. Il est de votre responsabilité de consulter périodiquement les conditions d'utilisation pour toute modification.",
},
help:{
Help_dis:"Aide",
h1:"Aide et support de SecureShare",
h1_P:"Nous sommes là pour vous aider à tirer le meilleur parti de SecureShare. Si vous avez des questions ou besoin d'assistance, n'hésitez pas à nous contacter.",
h2_1:"Contactez-nous",
h2_1_P1:"Vous pouvez nous envoyer un e-mail à l'adresse suivante :",
h2_1_P2:". Nous vous répondrons dans les 24 heures.",
h2_2:"Réseaux sociaux",
h2_2_P:"Vous pouvez également nous trouver sur les réseaux sociaux :",
h2_3:"Ressources supplémentaires",
h2_3_P:"Pour plus d'informations sur SecureShare, consultez les pages suivantes :",
},
about:{
h1:"À propos de SecureShare",
P1:"SecureShare est un outil gratuit et sécurisé de transfert de fichiers et de partage de presse-papiers conçu avec la confidentialité et la facilité d'utilisation à l'esprit. Notre mission est de fournir une solution simple mais puissante pour transférer des fichiers et partager du contenu entre appareils sans aucune restriction.",
P2:"Au cœur de SecureShare se trouve notre engagement envers la sécurité et la confidentialité. Nous utilisons un chiffrement de bout en bout pour garantir que vos données sont protégées pendant le processus de transfert, et nous ne stockons jamais vos fichiers ou le contenu de votre presse-papiers sur nos serveurs. Cela signifie que vos données restent locales et sous votre contrôle.",
P3:"Avec SecureShare, vous pouvez partager facilement du texte, des images et des fichiers de toute taille sans avoir besoin de vous inscrire ou de vous connecter. Notre plateforme est conçue pour être rapide, efficace et respectueuse de l'environnement, avec un accent sur une expérience fluide et conviviale.",
P4:"Nous croyons en l'autonomisation des utilisateurs pour qu'ils prennent le contrôle de leur vie numérique, et SecureShare est notre contribution à cette vision. Nous espérons que notre outil vous aidera à partager et à collaborer en toute sécurité avec vos amis, votre famille et vos collègues, sans compromettre votre vie privée ou votre sécurité.",
P5:"Pour plus d'informations ou des questions, veuillez consulter les pages suivantes :"
},
HowItWorks:{
h2: "Comment ça marche",
h2_P: "Partagez des fichiers et des messages instantanément en trois étapes simples",
btn_try: "Essayez maintenant →",
step1_title:"Tapez ou choisissez des fichiers",
step1_description:"Tapez votre message ou glissez-déposez des fichiers/dossiers dans la zone de sélection",
step2_title:"Rejoindre une salle",
step2_description:"Cliquez sur le bouton 'Rejoindre une salle' pour créer une session de partage",
step3_title:"Recevoir",
step3_description:"Entrez l'ID de la salle sur la page de réception et cliquez sur 'Rejoindre une salle' pour obtenir le contenu partagé",
},
SystemDiagram:{
h2: "Diagramme du système",
h2_P: "SecureShare : Vos données, votre contrôle. Simple, rapide et privé.",
},
KeyFeatures:{
h2: "Fonctionnalités clés",
h3_1: "Direct et sécurisé",
h3_1_P: "Vos fichiers voyagent directement de votre appareil à celui du destinataire, comme un tunnel secret auquel seuls vous deux avez accès. Avec un chiffrement de bout en bout, c'est comme si vos données parlaient une langue que seul le destinataire peut comprendre. Vous ne voulez plus partager ? Fermez simplement l'onglet de votre navigateur, et c'est comme raccrocher un appel téléphonique vous avez le contrôle.",
h3_2: "Synergie d'équipe",
h3_2_P: "Partagez avec toute votre équipe aussi facilement qu'avec une seule personne. Comme organiser une table ronde numérique, tout le monde reçoit les fichiers simultanément. Que vous collaboriez à un projet créatif ou distribuiez des documents importants, c'est comme si tout le monde était dans la même pièce, recevant votre vision partagée en même temps. Parfait pour les séances de brainstorming, les présentations d'équipe ou tout moment où plusieurs esprits doivent se connecter.",
h3_3: "Aucune limite, gestion intelligente",
h3_3_P: "Imaginez un pipeline magique qui peut transporter n'importe quoi, quelle que soit sa taille ! Envoyez des fichiers de toute taille, limités uniquement par l'espace disque disponible. Pour les fichiers particulièrement volumineux, choisissez où les enregistrer sur votre appareil. C'est comme avoir un service de livraison spécial qui ne ralentit pas votre ordinateur les fichiers vont directement sur le disque, gardant votre appareil rapide et réactif.",
h3_4: "Rapide comme une pensée",
h3_4_P: "Partagez du texte, des images et même des dossiers entiers aussi vite que vous pouvez y penser. C'est comme téléporter instantanément vos affaires numériques. Besoin d'envoyer un album photo entier ou un dossier rempli de documents ? Aucun problème ! C'est aussi simple que de partager un seul fichier.",
h3_5: "Écologique et propre",
h3_5_P: "Nous sommes comme une version numérique d'une conversation en face à face rien n'est stocké ailleurs. Cela signifie que nous sommes très respectueux de l'environnement, utilisant un minimum de ressources. C'est comme ne laisser aucune empreinte dans le monde numérique, en gardant les choses propres et vertes pour tout le monde.",
},
faqs:{
FAQ_dis:"Questions fréquemment posées",
question_0: "Les données sont-elles vraiment stockées localement et non transférées vers d'autres serveurs ?",
answer_0: "Oui, toutes les données sont traitées localement. Vous pouvez regarder la vidéo YouTube sur notre page d'accueil les fichiers peuvent toujours être transférés dans un réseau local même si Internet est déconnecté après avoir établi la connexion. À l'avenir, nous prévoyons d'ouvrir le code source pour que tout le monde puisse l'examiner.",
question_1: "Comment envoyer et recevoir des dossiers ?",
answer_1: "Envoyer un dossier est aussi simple qu'envoyer un fichier. Glissez-déposez le dossier dans la zone de sélection de fichiers ou cliquez sur la zone pour le sélectionner, puis appuyez sur \"Commencer l'envoi\". Côté réception, les utilisateurs peuvent télécharger directement ou choisir un répertoire de sauvegarde avant de télécharger. Le premier enregistre en mémoire, tandis que le second enregistre directement sur le disque.",
question_2: "Puis-je changer l'ID de la salle ?",
answer_2: "Oui, vous pouvez changer l'ID de la salle en toute chaîne de caractères que vous préférez.",
question_3: "Puis-je partager du contenu en continu ?",
answer_3: "Tant que vous restez connecté, vous pouvez cliquer manuellement sur le bouton \"Commencer l'envoi\" pour mettre à jour le contenu partagé chaque fois qu'il change.",
question_4: "Puis-je partager des fichiers avec plusieurs destinataires simultanément ?",
answer_4: "Bien sûr ! Il n'y a aucune différence entre une personne qui reçoit et plusieurs personnes qui reçoivent simultanément.",
question_5: "Mes données sont-elles sécurisées lorsque j'utilise SecureShare ?",
answer_5: "Absolument sécurisées. Vos données restent toujours locales, transférées entre appareils via une connexion chiffrée de bout en bout. Toutes les données transmises sont chiffrées, garantissant que seuls vous et le destinataire pouvez y accéder.",
question_6: "Dois-je créer un compte pour utiliser SecureShare ?",
answer_6: "Aucune inscription ou connexion n'est requise ouvrez simplement le site et commencez à l'utiliser. La commodité et la rapidité sont nos priorités.",
question_7: "Y a-t-il des limites de taille de fichier ?",
answer_7: "Aucune limite de taille de fichier ou de vitesse. Tant que vous avez suffisamment d'espace disque, vous pouvez transférer des fichiers de toute taille en définissant un répertoire de sauvegarde avant le téléchargement.",
question_8: "Puis-je partager des dossiers ou plusieurs fichiers à la fois ?",
answer_8: "Oui, partager plusieurs fichiers ou dossiers est aussi simple que de partager un seul fichier. Vous pouvez également ajouter des fichiers au transfert il suffit de cliquer sur \"Commencer l'envoi\" pour les mettre à jour pour le destinataire.",
question_9: "Comment puis-je arrêter de partager si je change d'avis ?",
answer_9: "Arrêter un partage est aussi simple que de fermer l'onglet ou la fenêtre du navigateur. Une fois que vous faites cela, la connexion est terminée, et aucune autre donnée ne peut être transférée.",
question_10: "L'utilisation de SecureShare ralentit-elle mon appareil ?",
answer_10: "Non, SecureShare est conçu pour être léger et efficace. Si vous définissez un répertoire de sauvegarde, toutes les données reçues sont écrites directement sur le disque, contournant la mémoire, ce qui aide à maintenir les performances de votre appareil.",
question_11: "Puis-je utiliser SecureShare hors ligne ?",
answer_11: "Oui, si l'expéditeur et le destinataire sont sur le même réseau local, ils peuvent rejoindre la même salle tout en étant connectés à Internet, puis se déconnecter. Le partage de fichiers fonctionnera toujours. Vous pouvez vous référer à la vidéo YouTube sur la page d'accueil pour plus de détails.",
question_12: "SecureShare utilise-t-il des serveurs ?",
answer_12: "Oui, il y a effectivement un serveur léger, qui est utilisé uniquement pour la signalisation afin d'établir une connexion chiffrée. Une fois la connexion établie, toutes les données sont transférées directement entre les appareils via la connexion chiffrée.",
question_13: "Quelle est la durée de validité des ID de salle ?",
answer_13: "La validité initiale d'un ID de salle est de 24 heures. Si un destinataire rejoint la salle, la validité est automatiquement prolongée de 24 heures à partir de ce moment.",
},
clipboard_btn:{
Pasted_dis:"Collé",
Copied_dis:"Copié",
},
fileUploadHandler:{
NoFileChosen_tips:"Aucun fichier sélectionné",
fileChosen_tips_template: "{fileNum} fichier(s) et {folderNum} dossier(s) sélectionné(s)",
Drag_tips:"Glissez-déposez des fichiers/dossiers ici ou cliquez pour choisir",
chosenDiagTitle:"Choisir le type de téléversement",
chosenDiagDescription:"Sélectionnez si vous souhaitez téléverser des fichiers ou un dossier",
SelectFile_dis:"Sélectionner des fichiers",
SelectFolder_dis:"Sélectionner un dossier",
},
FileTransferButton:{
SavedToDisk_tips:"Fichier déjà enregistré sur le disque",
CurrentFileTransferring_tips:"Le fichier est en cours de transfert",
OtherFileTransferring_tips:"Veuillez attendre que le transfert actuel soit terminé",
download_tips:"Cliquez pour télécharger le fichier",
Saved_dis:"Enregistré",
Waiting_dis:"En attente",
Download_dis:"Télécharger",
},
FileListDisplay:{
sending_dis: 'Envoi',
receiving_dis: 'Réception',
finish_dis:"terminé",
delete_dis:"Supprimer",
downloadNum_dis:"Nombre de téléchargements",
folder_tips_template:"Nom du dossier : {name} ({num} fichiers et {size}) au total",
folder_dis_template:" ({num} fichiers, {size})",
PopupDialog_title:"Recommandé : Choisir un répertoire de sauvegarde",
PopupDialog_description:"Nous recommandons de sélectionner un répertoire de sauvegarde pour enregistrer directement les fichiers sur votre disque. Cela facilite le transfert de fichiers volumineux et la synchronisation efficace des dossiers.",
chooseSavePath_tips:"Enregistrez des fichiers ou dossiers volumineux directement dans un répertoire sélectionné. 👉",
chooseSavePath_dis:"Choisir un emplacement de sauvegarde",
},
RetrieveMethod:{
P:"Félicitations 🎉 Le contenu partagé attend d'être récupéré :",
RoomId_tips:"Récupérer l'ID de salle : ",
copyRoomId_tips:"Copier l'ID de salle",
url_tips:"Récupérer via URL : ",
copyUrl_tips:"Copier l'URL de partage",
scanQR_tips:"Scannez le code QR pour recevoir 👇",
Copied_dis:"Copié",
Copy_QR_dis:"Copier le code QR",
download_QR_dis:"Télécharger le code QR",
},
ClipboardApp:{
fetchRoom_err:"Échec de l'obtention d'une salle. Veuillez réessayer.",
roomCheck:{//handleShareRoomCheck
empty_msg:"L'ID de salle ne doit pas être vide",
available_msg: 'La salle est disponible',
notAvailable_msg: 'La salle n\'est pas disponible, veuillez en essayer une autre',
},
channelOpen_msg:"'Le canal de données est ouvert, prêt à recevoir des données...'",
waitting_tips:"En attente de la connexion du destinataire. Veuillez garder cette page ouverte jusqu'à la fin du transfert. Sur ordinateur, vous pouvez minimiser le navigateur ou changer d'onglet. Sur mobile, veuillez garder le navigateur au premier plan.",
joinRoom: {
EmptyMsg: "Avertissement, l'ID de salle est vide",
DuplicateMsg: "L'ID de salle que vous avez entré est en double. Veuillez le réentrer.",
successMsg: "Rejoignez le salon avec succès ! Ne fermez pas cette page tant que le transfert n'est pas terminé. (Sur ordinateur, vous pouvez réduire le navigateur ou changer d'onglet ; sur mobile, ne mettez pas le navigateur en arrière-plan.)",
notExist: "La salle que vous essayez de rejoindre n'existe pas. Seul l'expéditeur peut créer une salle.",
failMsg: "Échec de la connexion à la salle :"
},
pickSaveMsg: "Enregistrer directement sur le disque ?",
roomStatus: {
senderEmptyMsg: "La salle est vide",
receiverEmptyMsg: "Vous pouvez accepter une invitation pour rejoindre la salle",
onlyOneMsg: "Vous êtes le seul ici",
peopleMsg_template: "{peerCount} personnes dans la salle",
connected_dis:"Connecté",
},
html:{//html 部分的消息
senderTab: "Envoyer",
retrieveTab: "Récupérer",
shareTitle_dis:"Contenu partagé",
retrieveTitle_dis:"Récupérer le contenu",
RoomStatus_dis:"Statut :",
Paste_dis:"Coller",
Copy_dis:"Copier",
inputRoomIdprompt: "Votre ID de salle (modifiable) :",
joinRoomBtn: "Rejoindre la salle",
startSendingBtn: "Commencer l'envoi",
readClipboardToRoomId: "Coller l'ID de salle",
enterRoomID_placeholder: "entrez l'ID de salle",
retrieveMethod: "Méthode de récupération",
inputRoomId_tips:"Votre ID de salle (modifiable) :",
joinRoom_dis:"Rejoindre la salle",
startSending_loadingText:"Envoyé",
startSending_dis:"Commencer l'envoi",
readClipboard_dis:"Coller l'ID de salle",
retrieveRoomId_placeholder:"Entrez l'ID de salle",
RetrieveMethodTitle:"Méthode de récupération",
}
},
home: {
h1: "Outil gratuit de transfert de fichiers et de presse-papiers en ligne sécurisé",
h1P: "Partagez facilement du texte, des images, des fichiers et des dossiers avec une confidentialité inégalée, entièrement gratuit et sans inscription nécessaire. Aucune restriction de taille ou de vitesse. Profitez de transferts sécurisés et chiffrés de bout en bout directement entre appareils sans aucun coût.",
h2_screenOnly: 'Essayez maintenant l\'outil de transfert de fichiers et de presse-papiers sécurisé',
h2_demo: "Voyez le partage de fichiers sécurisé en action",
h2P_demo: "Découvrez comment notre partage de fichiers local et chiffré de bout en bout protège votre vie privée",
watch_tips:"Vous pouvez également regarder la vidéo sur ces plateformes :",
youtube_tips: "Regarder SecureShare sur YouTube",
bilibili_tips:"Regarder SecureShare sur Bilibili",
}
},
}
+261
View File
@@ -0,0 +1,261 @@
import { Messages } from '@/types/messages'
export const ja: Messages = {
meta: {
home: {
title: "SecureShare: 無料P2Pファイル&クリップボード共有 | プライベート&アップロード不要",
description: "SecureShareは、サイズ制限や登録なしで即座に安全なP2Pファイル共有を可能にします。テキスト、画像、フォルダをエンドツーエンド暗号化でデバイス間で共有。チームコラボレーションやプライベートファイル転送に最適です。",
keywords: 'ファイル共有, 安全なファイル転送, P2Pファイル転送, webrtcファイル共有, プライベートクリップボード, チームコラボレーション, クロスデバイス共有, 暗号化ファイル転送, 登録不要ファイル共有, 無制限ファイル転送, フォルダ同期, モバイルファイル転送, 安全なメッセージング, 即時ファイル共有, プライベートデータ転送',
},
about: {
title: "SecureShareについて",
description: "SecureShareについて学び、安全でプライベートなファイル転送とクリップボード共有サービスを提供する私たちの使命、およびユーザーのプライバシーとデータ保護への取り組みについてご紹介します。"
},
faq:{
title: "SecureShare FAQ",
description: "SecureShareに関するよくある質問の回答を見つけましょう。ファイルの送信方法、クリップボードコンテンツの共有方法、安全でプライベートなデータ転送を確保する方法などが含まれます。",
keywords: 'SecureShare FAQ,よくある質問,安全なファイル共有FAQ,プライベートデータ共有ヘルプ,エンドツーエンド暗号化ファイル転送,安全なクリップボード共有サポート,SecureShareの使用方法,ファイル転送FAQ,プライバシー重視の共有質問,SecureShareトラブルシューティング',
},
help: {
title: "SecureShareヘルプとサポート",
description: "SecureShareサポートへの連絡方法に関する情報や、私たちのサービスについての詳細を提供するAbout、利用規約、プライバシーポリシーページへのリンクを見つけましょう。"
},
privacy: {
title: "SecureShareプライバシーポリシー",
description: "SecureShareがどのようにあなたのプライバシーとデータを保護するか、情報収集、データ保存とセキュリティ、第三者とのデータ共有を行わないという私たちの取り組みについて理解しましょう。"
},
terms: {
title: "SecureShare利用規約",
description: "SecureShareの利用規約を確認しましょう。サービスの適切な使用、データプライバシーとセキュリティ、責任の制限に関する情報が含まれます。"
},
},
text: {
Header:{
Home_dis:"ホーム",
Blog_dis: "ブログ",
About_dis:"について",
Help_dis:"ヘルプ",
FAQ_dis:"FAQ",
Terms_dis:"利用規約",
Privacy_dis:"プライバシー",
},
Footer:{
CopyrightNotice:"SecureShare. All rights reserved.",
Terms_dis:"利用規約",
Privacy_dis:"プライバシーポリシー",
SupportedLanguages:"対応言語"
},
privacy:{
PrivacyPolicy_dis:"プライバシーポリシー",
h1:"SecureShareプライバシーポリシー",
h1_P:"SecureShareでは、あなたのプライバシーを保護し、個人情報を守ることに尽力しています。このプライバシーポリシーでは、私たちがサービスを利用する際に提供されるデータをどのように収集、使用、保護するかを説明します。",
h2_1:"情報収集",
h2_1_P:"SecureShareは、ユーザーから個人を特定できる情報を収集しません。私たちのサービスを利用するために登録やアカウント作成は必要ありません。収集する唯一の情報は、ルームIDと他のユーザーと共有するファイル/クリップボードデータです。",
h2_2:"データ保存とセキュリティ",
h2_2_P:"私たちはあなたのデータをサーバーに保存しません。すべてのファイル転送とクリップボード共有はエンドツーエンド暗号化を使用して処理され、あなたの情報は安全に保たれ、意図した受信者のみがアクセスできます。転送が完了すると、データは私たちのシステムから削除されます。",
h2_3:"第三者サービス",
h2_3_P:"SecureShareは、いかなる第三者サービスやプラットフォームとも統合しません。私たちはあなたのデータを第三者と共有または販売しません。",
h2_4:"プライバシーポリシーの変更",
h2_4_P:"私たちは、プライバシーポリシーを随時更新して、私たちの慣行や適用される法律の変更を反映する場合があります。変更は、更新されたポリシーをウェブサイトに掲載した時点で即座に有効になります。定期的にプライバシーポリシーを確認し、更新を確認するのはあなたの責任です。",
h2_5:"お問い合わせ",
h2_5_P:"私たちのプライバシー慣行について質問や懸念がある場合は、お気軽にお問い合わせください。",
},
terms:{
TermsOfUse_dis:"利用規約",
h1:"SecureShare利用規約",
h1_P:"SecureShareサービスを利用することにより、あなたはこれらの利用規約に拘束されることに同意します。これらの規約に同意しない場合は、サービスを利用しないでください。",
h2_1:"サービスの使用",
h2_1_P:"SecureShareは、いかなる制限もなく無料で提供されます。",
h2_2:"データプライバシーとセキュリティ",
h2_2_P:"私たちはあなたのデータのプライバシーとセキュリティを非常に重視しています。すべてのファイル転送とクリップボード共有はエンドツーエンド暗号化で保護され、私たちはあなたのデータをサーバーに保存しません。ただし、転送プロセス中のデータのセキュリティを保証することはできず、あなたは自己責任でサービスを利用します。",
h2_3:"適切な使用",
h2_3_P:"あなたは、SecureShareを違法、虐待的、または有害な目的で使用しないことに同意します。これには、違法、著作権保護、または悪意のあるコンテンツの転送、および他の人を嫌がらせたりなりすましたりするためのサービスの使用が含まれますが、これに限定されません。",
h2_4:"責任の制限",
h2_4_P:"SecureShareは「現状のまま」で提供され、いかなる保証もありません。私たちは、データ損失、システム障害、またはサービスの中断など、私たちのサービスの使用に起因する直接的、間接的、または結果的な損害について責任を負いません。",
h2_5:"利用規約の変更",
h2_5_P:"私たちは、これらの利用規約を随時更新する権利を留保します。変更は、更新された規約をウェブサイトに掲載した時点で即座に有効になります。定期的に利用規約を確認し、変更を確認するのはあなたの責任です。",
},
help:{
Help_dis:"ヘルプ",
h1:"SecureShareヘルプとサポート",
h1_P:"SecureShareを最大限に活用するためのお手伝いをします。質問やサポートが必要な場合は、お気軽にお問い合わせください。",
h2_1:"お問い合わせ",
h2_1_P1:"メールでお問い合わせください。",
h2_1_P2:"。24時間以内に返信いたします。",
h2_2:"ソーシャルメディア",
h2_2_P:"ソーシャルメディアでも私たちを見つけることができます:",
h2_3:"追加リソース",
h2_3_P:"SecureShareの詳細については、以下のページをご確認ください:",
},
about:{
h1:"SecureShareについて",
P1:"SecureShareは、プライバシーと使いやすさを考慮して設計された無料で安全なファイル転送とクリップボード共有ツールです。私たちの使命は、制限なくデバイス間でファイルを転送し、コンテンツを共有するためのシンプルで強力なソリューションを提供することです。",
P2:"SecureShareの核心は、セキュリティとプライバシーへの取り組みです。私たちはエンドツーエンド暗号化を使用して、転送プロセス中にあなたのデータが保護されることを保証し、ファイルやクリップボードコンテンツをサーバーに保存しません。これにより、あなたのデータはローカルに保たれ、あなたの管理下に置かれます。",
P3:"SecureShareを使用すると、登録やログインなしでテキスト、画像、任意のサイズのファイルを簡単に共有できます。私たちのプラットフォームは、高速で効率的、環境に優しい設計で、シームレスでユーザーフレンドリーな体験を提供することに重点を置いています。",
P4:"私たちは、ユーザーがデジタルライフをコントロールできるようにすることを信じており、SecureShareはそのビジョンへの貢献です。私たちのツールが、プライバシーやセキュリティを損なうことなく、友人、家族、同僚と安全に共有し、コラボレーションするのに役立つことを願っています。",
P5:"詳細や質問については、以下のページをご覧ください:"
},
HowItWorks:{
h2: "使い方",
h2_P: "3つの簡単なステップでファイルやメッセージを即座に共有",
btn_try: "今すぐ試す →",
step1_title:"入力またはファイルを選択",
step1_description:"メッセージを入力するか、ファイル/フォルダを選択エリアにドラッグ&ドロップ",
step2_title:"ルームに参加",
step2_description:"「ルームに参加」ボタンをクリックして共有セッションを作成",
step3_title:"受信",
step3_description:"受信ページでルームIDを入力し、「ルームに参加」をクリックして共有コンテンツを取得",
},
SystemDiagram:{
h2: "システム図",
h2_P: "SecureShare: あなたのデータ、あなたの管理。シンプルで高速、プライベート。",
},
KeyFeatures:{
h2: "主な特徴",
h3_1: "直接かつ安全",
h3_1_P: "あなたのファイルは、あなたのデバイスから受信者のデバイスに直接送信されます。エンドツーエンド暗号化により、データは意図した受信者のみが理解できる言語で話しているかのようです。共有をやめたい場合は、ブラウザタブを閉じるだけで、電話を切るかのように簡単です。あなたがコントロールします。",
h3_2: "チームシナジー",
h3_2_P: "1人と共有するのと同じくらい簡単に、チーム全体と共有できます。デジタル円卓をホストするかのように、全員が同時にファイルを受け取ります。クリエイティブプロジェクトでのコラボレーションや重要なドキュメントの配布に最適です。全員が同じ部屋にいるかのように、共有ビジョンを同時に受け取ります。ブレインストーミングセッション、チームプレゼンテーション、または複数の人が同時に接続する必要がある場面に最適です。",
h3_3: "制限なし、スマートな処理",
h3_3_P: "どんなに大きくても何でも運べる魔法のパイプラインを想像してください!ディスク容量さえあれば、どんなサイズのファイルでも送信できます。特に大きなファイルの場合は、デバイス上の保存場所を選択できます。コンピュータの速度を低下させない特別な配達サービスのように、ファイルは直接ディスクに保存され、デバイスの高速性と応答性を維持します。",
h3_4: "思考のように迅速",
h3_4_P: "テキスト、画像、さらにはフォルダ全体を瞬時に共有できます。デジタルデータをテレポートさせるかのようです。写真アルバム全体やドキュメントが詰まったフォルダを送信する必要がありますか?問題ありません!単一のファイルを共有するのと同じくらい簡単です。",
h3_5: "環境に優しくクリーン",
h3_5_P: "私たちは、デジタル版の対面会話のようなものです。他の場所には何も保存されません。これは、最小限のリソースを使用し、非常に環境に優しいことを意味します。デジタル世界に足跡を残さず、すべての人にとってクリーンで環境に優しい状態を保ちます。",
},
faqs:{
FAQ_dis:"よくある質問",
question_0: "データは本当にローカルに保存され、他のサーバーに転送されませんか?",
answer_0: "はい、すべてのデータはローカルで処理されます。ホームページのYouTubeビデオを確認してください。接続が確立された後、インターネットが切断されてもローカルネットワーク内でファイルを転送できます。将来的には、コードをオープンソース化して誰でも確認できるようにする予定です。",
question_1: "フォルダを送受信するにはどうすればいいですか?",
answer_1: "フォルダを送信するのは、ファイルを送信するのと同じくらい簡単です。フォルダをファイル選択エリアにドラッグするか、エリアをクリックして選択し、「送信開始」ボタンをクリックします。受信側では、ユーザーは直接ダウンロードするか、ダウンロード前に保存ディレクトリを選択できます。前者はメモリに保存され、後者は直接ディスクに保存されます。",
question_2: "ルームIDを変更できますか?",
answer_2: "はい、ルームIDを任意の文字列に変更できます。",
question_3: "コンテンツを継続的に共有できますか?",
answer_3: "接続が維持されている限り、共有コンテンツが変更されるたびに手動で「送信開始」ボタンをクリックして更新できます。",
question_4: "複数の受信者と同時にファイルを共有できますか?",
answer_4: "もちろんです!1人が受信するのと複数人が同時に受信するのに違いはありません。",
question_5: "SecureShareを使用する際にデータは安全ですか?",
answer_5: "完全に安全です。あなたのデータは常にローカルに保たれ、暗号化されたエンドツーエンド接続を介してデバイス間で転送されます。すべての転送データは暗号化され、あなたと受信者のみがアクセスできます。",
question_6: "SecureShareを使用するためにアカウントを作成する必要がありますか?",
answer_6: "登録やログインは不要です。サイトを開いてすぐに使用できます。便利さとスピードを優先しています。",
question_7: "ファイルサイズに制限はありますか?",
answer_7: "ファイルサイズや速度に制限はありません。十分なディスク容量があれば、ダウンロード前に保存ディレクトリを設定することで、任意のサイズのファイルを転送できます。",
question_8: "フォルダや複数のファイルを一度に共有できますか?",
answer_8: "はい、複数のファイルやフォルダを共有するのは、単一のファイルを共有するのと同じくらい簡単です。転送にファイルを追加することもできます。「送信開始」をクリックして、受信者に更新します。",
question_9: "気が変わった場合、共有を停止するにはどうすればいいですか?",
answer_9: "共有を停止するのは、ブラウザタブやウィンドウを閉じるのと同じくらい簡単です。これを行うと、接続が終了し、それ以上のデータ転送は行われません。",
question_10: "SecureShareを使用するとデバイスが遅くなりますか?",
answer_10: "いいえ、SecureShareは軽量で効率的に設計されています。保存ディレクトリを設定すると、すべての受信データはメモリをバイパスして直接ディスクに書き込まれるため、デバイスのパフォーマンスが維持されます。",
question_11: "オフラインでSecureShareを使用できますか?",
answer_11: "はい、送信者と受信者が同じローカルネットワーク上にある場合、インターネットに接続している間に同じルームに参加し、その後インターネットから切断してもファイル共有は機能します。詳細については、ホームページのYouTubeビデオを参照してください。",
question_12: "SecureShareはサーバーを使用しますか?",
answer_12: "はい、軽量のサーバーが存在しますが、暗号化接続を確立するためのシグナリングにのみ使用されます。接続が確立されると、すべてのデータは暗号化接続を介してデバイス間で直接転送されます。",
question_13: "ルームIDの有効期間はどのくらいですか?",
answer_13: "ルームIDの初期有効期間は24時間です。受信者がルームに参加すると、その時点から24時間自動的に延長されます。",
},
clipboard_btn:{
Pasted_dis:"貼り付け済み",
Copied_dis:"コピー済み",
},
fileUploadHandler:{
NoFileChosen_tips:"ファイルが選択されていません",
fileChosen_tips_template: "{fileNum} ファイルと {folderNum} フォルダが選択されました",
Drag_tips:"ファイル/フォルダをここにドラッグ&ドロップするか、クリックして選択",
chosenDiagTitle:"アップロードタイプを選択",
chosenDiagDescription:"ファイルまたはフォルダをアップロードするか選択してください",
SelectFile_dis:"ファイルを選択",
SelectFolder_dis:"フォルダを選択",
},
FileTransferButton:{
SavedToDisk_tips:"ファイルは既にディスクに保存されています",
CurrentFileTransferring_tips:"ファイルが転送中です",
OtherFileTransferring_tips:"現在の転送が完了するまでお待ちください",
download_tips:"クリックしてファイルをダウンロード",
Saved_dis:"保存済み",
Waiting_dis:"待機中",
Download_dis:"ダウンロード",
},
FileListDisplay:{
sending_dis: '送信中',
receiving_dis: '受信中',
finish_dis:"完了",
delete_dis:"削除",
downloadNum_dis:"ダウンロード回数",
folder_tips_template:"フォルダ名:{name} ({num} ファイルと {size})",
folder_dis_template:" ({num} ファイル, {size})",
PopupDialog_title:"推奨: 保存ディレクトリを選択",
PopupDialog_description:"大きなファイルを転送し、フォルダを効率的に同期するために、保存ディレクトリを選択することをお勧めします。",
chooseSavePath_tips:"大きなファイルやフォルダを選択したディレクトリに直接保存します。👉",
chooseSavePath_dis:"保存場所を選択",
},
RetrieveMethod:{
P:"おめでとう 🎉 共有コンテンツが取得待ちです:",
RoomId_tips:"ルームIDを取得:",
copyRoomId_tips:"ルームIDをコピー",
url_tips:"URLを使用して取得:",
copyUrl_tips:"共有URLをコピー",
scanQR_tips:"QRコードをスキャンして受信 👇",
Copied_dis:"コピー済み",
Copy_QR_dis:"QRコードをコピー",
download_QR_dis:"QRコードをダウンロード",
},
ClipboardApp:{
fetchRoom_err:"ルームの取得に失敗しました。もう一度お試しください。",
roomCheck:{
empty_msg:"ルームIDは空にできません",
available_msg: 'ルームは利用可能です',
notAvailable_msg: 'ルームは利用できません。別のルームをお試しください',
},
channelOpen_msg:"データチャネルが開かれ、データを受信する準備ができました...",
waitting_tips:"受信者が接続するのを待っています。転送が完了するまでこのページを開いたままにしてください。デスクトップでは、ブラウザを最小化したり、タブを切り替えたりできます。モバイルでは、ブラウザをフォアグラウンドに保ってください。",
joinRoom: {
EmptyMsg: "警告、ルームIDが空です",
DuplicateMsg: "入力したルームIDが重複しています。再入力してください。",
successMsg: "ルームに成功して参加しました!転送が完了するまでこのページを閉じないでください。(PCではブラウザを最小化したりタブを切り替えたりできます。モバイルではブラウザをバックグラウンドにしないでください。)",
notExist: "参加しようとしているルームは存在しません。送信者のみがルームを作成できます。",
failMsg: "ルームへの参加に失敗しました:"
},
pickSaveMsg: "ディスクに直接保存しますか?",
roomStatus: {
senderEmptyMsg: "ルームは空です",
receiverEmptyMsg: "招待を受けてルームに参加できます",
onlyOneMsg: "あなただけがここにいます",
peopleMsg_template: "{peerCount} 人がルームにいます",
connected_dis:"接続済み",
},
html:{
senderTab: "送信",
retrieveTab: "取得",
shareTitle_dis:"共有コンテンツ",
retrieveTitle_dis:"取得コンテンツ",
RoomStatus_dis:"ステータス:",
Paste_dis:"貼り付け",
Copy_dis:"コピー",
inputRoomIdprompt: "ルームID(編集可能):",
joinRoomBtn: "ルームに参加",
startSendingBtn: "送信開始",
readClipboardToRoomId: "ルームIDを貼り付け",
enterRoomID_placeholder: "ルームIDを入力",
retrieveMethod: "取得方法",
inputRoomId_tips:"ルームID(編集可能):",
joinRoom_dis:"ルームに参加",
startSending_loadingText:"送信済み",
startSending_dis:"送信開始",
readClipboard_dis:"ルームIDを貼り付け",
retrieveRoomId_placeholder:"ルームIDを入力",
RetrieveMethodTitle:"取得方法",
}
},
home: {
h1: "無料で安全なオンラインクリップボード&ファイル転送ツール",
h1P: "テキスト、画像、ファイル、フォルダをプライバシーを守りながら簡単に共有できます。登録不要で完全無料。サイズや速度に制限はありません。エンドツーエンド暗号化された転送をデバイス間で直接行い、コストゼロで利用できます。",
h2_screenOnly: '今すぐ安全なクリップボード&ファイル転送ツールを試す',
h2_demo: "安全なファイル共有のデモを見る",
h2P_demo: "ローカルファースト、エンドツーエンド暗号化されたファイル共有がどのようにプライバシーを保護するかを見てください",
watch_tips:"これらのプラットフォームでもビデオを視聴できます:",
youtube_tips: "YouTubeでSecureShareを見る",
bilibili_tips:"BilibiliでSecureShareを見る",
}
},
}
+265
View File
@@ -0,0 +1,265 @@
import { Messages } from '@/types/messages'
export const ko: Messages = {
meta: {
home: {
title: "SecureShare: 무료 P2P 파일 및 클립보드 공유 | 개인 정보 보호 및 업로드 없음",
description: "SecureShare는 크기 제한 없이, 등록 없이 즉각적이고 안전한 P2P 파일 공유를 가능하게 합니다. 텍스트, 이미지, 폴더를 기기 간에 엔드투엔드 암호화로 공유하세요. 팀 협업 및 개인 파일 전송에 완벽합니다.",
keywords: '파일 공유, 안전한 파일 전송, P2P 파일 전송, WebRTC 파일 공유, 개인 클립보드, 팀 협업, 기기 간 공유, 암호화 파일 전송, 등록 없는 파일 공유, 무제한 파일 전송, 폴더 동기화, 모바일 파일 전송, 안전한 메시징, 즉각적인 파일 공유, 개인 데이터 전송',
},
about: {
title: "SecureShare 소개",
description: "SecureShare에 대해 알아보세요. 우리는 안전하고 개인적인 파일 전송 및 클립보드 공유 서비스를 제공하기 위해 노력하며, 사용자의 개인 정보와 데이터 보호를 최우선으로 합니다."
},
faq:{
title: "SecureShare FAQ",
description: "SecureShare에 대해 자주 묻는 질문에 대한 답변을 찾아보세요. 파일을 보내는 방법, 클립보드 콘텐츠를 공유하는 방법, 안전하고 개인적인 데이터 전송을 보장하는 방법 등을 포함합니다.",
keywords: 'SecureShare FAQ, 자주 묻는 질문, 안전한 파일 공유 FAQ, 개인 데이터 공유 도움말, 엔드투엔드 암호화 파일 전송, 안전한 클립보드 공유 지원, SecureShare 사용 방법, 파일 전송 FAQ, 개인 정보 보호 중심 공유 질문, SecureShare 문제 해결',
},
help: {
title: "SecureShare 도움말 및 지원",
description: "SecureShare 지원팀에 문의하는 방법에 대한 정보와 서비스에 대한 자세한 내용을 확인할 수 있는 소개, 이용 약관 및 개인정보 보호정책 페이지 링크를 찾아보세요."
},
privacy: {
title: "SecureShare 개인정보 보호정책",
description: "SecureShare가 어떻게 귀하의 개인 정보와 데이터를 보호하는지 이해하세요. 정보 수집, 데이터 저장 및 보안에 대한 세부 사항과 제3자와 데이터를 공유하지 않겠다는 우리의 약속을 포함합니다."
},
terms: {
title: "SecureShare 이용 약관",
description: "SecureShare의 이용 약관을 검토하세요. 서비스의 허용 가능한 사용, 데이터 개인 정보 보호 및 보안, 책임 제한에 대한 정보를 포함합니다."
},
},
text: {
Header:{
Home_dis:"홈",
Blog_dis: "블로그",
About_dis:"소개",
Help_dis:"도움말",
FAQ_dis:"FAQ",
Terms_dis:"이용 약관",
Privacy_dis:"개인정보 보호",
},
Footer:{
CopyrightNotice:"SecureShare. 모든 권리 보유.",
Terms_dis:"이용 약관",
Privacy_dis:"개인정보 보호정책",
SupportedLanguages:"지원 언어"
},
privacy:{
PrivacyPolicy_dis:"개인정보 보호정책",
h1:"SecureShare 개인정보 보호정책",
h1_P:"SecureShare는 귀하의 개인 정보를 보호하고 안전하게 지키기 위해 최선을 다하고 있습니다. 이 개인정보 보호정책은 귀하가 우리 서비스를 사용하는 동안 제공하는 데이터를 어떻게 수집, 사용 및 보호하는지 설명합니다.",
h2_1:"정보 수집",
h2_1_P:"SecureShare는 사용자로부터 개인 식별 정보를 수집하지 않습니다. 우리 서비스를 사용하기 위해 등록이나 계정 생성이 필요하지 않습니다. 우리가 수집하는 유일한 정보는 방 ID와 귀하가 다른 사용자와 공유하기로 선택한 파일/클립보드 데이터입니다.",
h2_2:"데이터 저장 및 보안",
h2_2_P:"우리는 귀하의 데이터를 서버에 저장하지 않습니다. 모든 파일 전송 및 클립보드 공유는 엔드투엔드 암호화를 사용하여 처리되며, 귀하의 정보는 안전하게 보호되고 의도된 수신자만 접근할 수 있습니다. 전송이 완료되면 데이터는 우리 시스템에서 제거됩니다.",
h2_3:"제3자 서비스",
h2_3_P:"SecureShare는 제3자 서비스나 플랫폼과 통합되지 않습니다. 우리는 귀하의 데이터를 제3자와 공유하거나 판매하지 않습니다.",
h2_4:"개인정보 보호정책 변경",
h2_4_P:"우리는 때때로 이 개인정보 보호정책을 업데이트하여 우리의 관행이나 적용 가능한 법률의 변경 사항을 반영할 수 있습니다. 모든 변경 사항은 업데이트된 정책을 웹사이트에 게시함으로써 즉시 효력이 발생합니다. 귀하는 정기적으로 개인정보 보호정책을 검토하여 업데이트를 확인할 책임이 있습니다.",
h2_5:"문의하기",
h2_5_P:"개인정보 보호 관행에 대해 궁금한 점이 있으면 언제든지 다음 주소로 문의하십시오:",
},
terms:{
TermsOfUse_dis:"이용 약관",
h1:"SecureShare 이용 약관",
h1_P:"SecureShare 서비스를 사용함으로써 귀하는 본 이용 약관에 동의하게 됩니다. 이 약관에 동의하지 않으면 서비스를 사용하지 마십시오.",
h2_1:"서비스 사용",
h2_1_P:"SecureShare는 어떠한 제한 없이 무료 서비스로 제공됩니다.",
h2_2:"데이터 개인 정보 보호 및 보안",
h2_2_P:"우리는 귀하의 데이터의 개인 정보 보호와 보안을 매우 중요하게 생각합니다. 모든 파일 전송 및 클립보드 공유는 엔드투엔드 암호화로 보호되며, 우리는 귀하의 데이터를 서버에 저장하지 않습니다. 그러나 전송 과정에서 데이터의 보안을 보장할 수 없으며, 귀하는 자신의 책임 하에 서비스를 사용합니다.",
h2_3:"허용 가능한 사용",
h2_3_P:"귀하는 SecureShare를 불법적, 남용적 또는 유해한 목적으로 사용하지 않기로 동의합니다. 이는 불법적, 저작권이 있는 또는 악성 콘텐츠의 전송 및 타인을 괴롭히거나 사칭하기 위해 서비스를 사용하는 것을 포함하되 이에 국한되지 않습니다.",
h2_4:"책임 제한",
h2_4_P:"SecureShare는 어떠한 보증도 없이 '있는 그대로' 제공됩니다. 우리는 데이터 손실, 시스템 오류 또는 서비스 중단을 포함하되 이에 국한되지 않는 서비스 사용으로 인한 직접적, 간접적 또는 결과적 손해에 대해 책임을 지지 않습니다.",
h2_5:"이용 약관 변경",
h2_5_P:"우리는 언제든지 본 이용 약관을 업데이트할 권리를 보유합니다. 모든 변경 사항은 업데이트된 약관을 웹사이트에 게시함으로써 즉시 효력이 발생합니다. 귀하는 정기적으로 이용 약관을 검토하여 변경 사항을 확인할 책임이 있습니다.",
},
help:{
Help_dis:"도움말",
h1:"SecureShare 도움말 및 지원",
h1_P:"SecureShare를 최대한 활용할 수 있도록 도와드리겠습니다. 질문이 있거나 도움이 필요하면 언제든지 문의하십시오.",
h2_1:"문의하기",
h2_1_P1:"다음 주소로 이메일을 보내주십시오:",
h2_1_P2:". 24시간 이내에 답변드리겠습니다.",
h2_2:"소셜 미디어",
h2_2_P:"소셜 미디어에서도 저희를 찾을 수 있습니다:",
h2_3:"추가 자료",
h2_3_P:"SecureShare에 대한 자세한 정보는 다음 페이지를 확인하십시오:",
},
about:{
h1:"SecureShare 소개",
P1:"SecureShare는 개인 정보 보호와 사용 편의성을 염두에 두고 설계된 무료 및 안전한 파일 전송 및 클립보드 공유 도구입니다. 우리의 목표는 제한 없이 기기 간에 파일을 전송하고 콘텐츠를 공유할 수 있는 간단하지만 강력한 솔루션을 제공하는 것입니다.",
P2:"SecureShare의 핵심은 보안과 개인 정보 보호에 대한 우리의 약속입니다. 우리는 엔드투엔드 암호화를 사용하여 전송 과정에서 귀하의 데이터가 보호되도록 하며, 귀하의 파일이나 클립보드 콘텐츠를 서버에 저장하지 않습니다. 이는 귀하의 데이터가 로컬로 유지되고 귀하의 통제 하에 있음을 의미합니다.",
P3:"SecureShare를 사용하면 등록이나 로그인 없이 텍스트, 이미지 및 모든 크기의 파일을 쉽게 공유할 수 있습니다. 우리의 플랫폼은 빠르고 효율적이며 환경 친화적으로 설계되었으며, 원활하고 사용자 친화적인 경험을 제공하는 데 중점을 둡니다.",
P4:"우리는 사용자가 디지털 생활을 통제할 수 있도록 돕는 것을 믿으며, SecureShare는 그 비전에 대한 우리의 기여입니다. 우리의 도구가 귀하의 개인 정보나 보안을 손상시키지 않고 친구, 가족 및 동료와 안전하게 공유하고 협력할 수 있도록 도와줄 것을 희망합니다.",
P5:"자세한 정보나 질문이 있으면 다음 페이지를 방문하십시오:"
},
HowItWorks:{
h2: "작동 방식",
h2_P: "세 가지 간단한 단계로 파일과 메시지를 즉시 공유하세요",
btn_try: "지금 사용해 보기 →",
step1_title:"텍스트 입력 또는 파일 선택",
step1_description:"메시지를 입력하거나 파일/폴더를 선택 영역으로 드래그 앤 드롭하세요",
step2_title:"방 참가",
step2_description:"'방 참가' 버튼을 클릭하여 공유 세션을 생성하세요",
step3_title:"받기",
step3_description:"받기 페이지에서 방 ID를 입력하고 '방 참가'를 클릭하여 공유된 콘텐츠를 받으세요",
},
SystemDiagram:{
h2: "시스템 다이어그램",
h2_P: "SecureShare: 귀하의 데이터, 귀하의 통제. 간단하고 빠르며 개인적입니다.",
},
KeyFeatures:{
h2: "주요 기능",
h3_1: "직접적이고 안전한",
h3_1_P: "귀하의 파일은 귀하의 기기에서 수신자의 기기로 직접 이동하며, 오직 귀하와 수신자만 접근할 수 있는 비밀 터널과 같습니다. 엔드투엔드 암호화를 사용하면 귀하의 데이터는 오직 의도된 수신자만 이해할 수 있는 언어를 사용하는 것과 같습니다. 더 이상 공유하고 싶지 않으세요? 브라우저 탭을 닫기만 하면 전화를 끊는 것과 같습니다 – 귀하가 통제합니다.",
h3_2: "팀 시너지",
h3_2_P: "전체 팀과 한 사람과 공유하는 것처럼 쉽게 공유하세요. 디지털 원탁 회의를 주최하는 것처럼 모든 사람이 동시에 파일을 받습니다. 창의적인 프로젝트를 협업하거나 중요한 문서를 배포할 때, 모든 사람이 같은 방에 있는 것처럼 귀하의 공유된 비전을 동시에 받습니다. 브레인스토밍 세션, 팀 프레젠테이션 또는 여러 사람이 연결되어야 하는 순간에 완벽합니다.",
h3_3: "제한 없음, 스마트 처리",
h3_3_P: "크기에 상관없이 모든 것을 전송할 수 있는 마법의 파이프라인을 상상해보세요! 디스크 공간만 충분하다면 어떤 크기의 파일도 전송할 수 있습니다. 특히 큰 파일의 경우 저장 위치를 선택할 수 있습니다. 이는 컴퓨터를 느리게 하지 않는 특별한 배달 서비스와 같습니다 – 파일은 직접 디스크에 저장되어 기기가 빠르고 반응적입니다.",
h3_4: "생각만큼 빠른",
h3_4_P: "텍스트, 이미지 및 전체 폴더를 생각하는 속도만큼 빠르게 공유하세요. 디지털 자료를 순간적으로 전송하는 것과 같습니다. 전체 사진 앨범이나 문서가 가득한 폴더를 보내야 하나요? 문제 없습니다! 단일 파일을 공유하는 것만큼 쉽습니다.",
h3_5: "친환경적이고 깨끗한",
h3_5_P: "우리는 디지털 세계에서 대면 대화와 같은 존재입니다 – 아무것도 다른 곳에 저장되지 않습니다. 이는 우리가 최소한의 리소스를 사용하여 매우 환경 친화적임을 의미합니다. 디지털 세계에 발자국을 남기지 않고 모든 사람을 위해 깨끗하고 친환경적으로 유지합니다.",
},
faqs:{
FAQ_dis:"자주 묻는 질문",
question_0: "데이터가 정말로 로컬에 저장되고 다른 서버로 전송되지 않나요?",
answer_0: "예, 모든 데이터는 로컬로 처리됩니다. 홈페이지의 YouTube 동영상을 확인하세요 – 인터넷 연결이 끊긴 후에도 로컬 네트워크 내에서 파일을 전송할 수 있습니다. 앞으로 코드를 오픈소스로 공개하여 누구나 검토할 수 있도록 할 계획입니다.",
question_1: "폴더를 어떻게 보내고 받나요?",
answer_1: "폴더를 보내는 것은 파일을 보내는 것만큼 간단합니다. 폴더를 파일 선택 영역으로 드래그하거나 영역을 클릭하여 선택한 후 '전송 시작' 버튼을 누르세요. 받는 쪽에서는 사용자가 직접 다운로드하거나 다운로드 전 저장 디렉토리를 선택할 수 있습니다. 전자는 메모리에 저장되고 후자는 직접 디스크에 저장됩니다.",
question_2: "방 ID를 변경할 수 있나요?",
answer_2: "예, 방 ID를 원하는 문자열로 변경할 수 있습니다.",
question_3: "콘텐츠를 지속적으로 공유할 수 있나요?",
answer_3: "연결된 상태라면 콘텐츠가 변경될 때마다 '전송 시작' 버튼을 수동으로 클릭하여 공유 콘텐츠를 업데이트할 수 있습니다.",
question_4: "여러 명의 수신자와 동시에 파일을 공유할 수 있나요?",
answer_4: "물론입니다! 한 명이 받는 것과 여러 명이 동시에 받는 것 사이에 차이가 없습니다.",
question_5: "SecureShare를 사용할 때 내 데이터는 안전한가요?",
answer_5: "절대적으로 안전합니다. 귀하의 데이터는 항상 로컬에 유지되며, 암호화된 엔드투엔드 연결을 통해 기기 간에 전송됩니다. 전송된 모든 데이터는 암호화되어 귀하와 수신자만 접근할 수 있습니다.",
question_6: "SecureShare를 사용하려면 계정을 만들어야 하나요?",
answer_6: "등록이나 로그인이 필요 없습니다 – 사이트를 열고 바로 사용하세요. 편의성과 속도가 우리의 우선 순위입니다.",
question_7: "파일 크기 제한이 있나요?",
answer_7: "파일 크기나 속도에 제한이 없습니다. 디스크 공간만 충분하다면 다운로드 전 저장 디렉토리를 설정하여 어떤 크기의 파일도 전송할 수 있습니다.",
question_8: "폴더나 여러 파일을 한 번에 공유할 수 있나요?",
answer_8: "예, 여러 파일이나 폴더를 공유하는 것은 단일 파일을 공유하는 것만큼 간단합니다. 전송에 파일을 추가하려면 '전송 시작'을 클릭하여 수신자에게 업데이트하면 됩니다.",
question_9: "마음이 바뀌면 공유를 중지할 수 있나요?",
answer_9: "공유를 중지하는 것은 브라우저 탭이나 창을 닫는 것만큼 간단합니다. 이렇게 하면 연결이 종료되고 더 이상 데이터를 전송할 수 없습니다.",
question_10: "SecureShare를 사용하면 내 기기가 느려지나요?",
answer_10: "아니요, SecureShare는 가볍고 효율적으로 설계되었습니다. 저장 디렉토리를 설정하면 모든 수신 데이터는 메모리를 우회하여 직접 디스크에 기록되므로 기기 성능을 유지하는 데 도움이 됩니다.",
question_11: "오프라인에서 SecureShare를 사용할 수 있나요?",
answer_11: "예, 보내는 사람과 받는 사람이 동일한 로컬 네트워크에 있다면 인터넷에 연결된 상태에서 동일한 방에 참여한 후 인터넷 연결을 끊을 수 있습니다. 파일 공유는 계속 작동합니다. 홈페이지의 YouTube 동영상을 참조하세요.",
question_12: "SecureShare는 서버를 사용하나요?",
answer_12: "예, 실제로 경량 서버가 있습니다. 이 서버는 암호화된 연결을 설정하기 위한 시그널링에만 사용됩니다. 연결이 설정되면 모든 데이터는 암호화된 연결을 통해 기기 간에 직접 전송됩니다.",
question_13: "방 ID의 유효 기간은 얼마인가요?",
answer_13: "방 ID의 초기 유효 기간은 24시간입니다. 수신자가 방에 참여하면 해당 시점부터 24시간 동안 유효 기간이 자동으로 연장됩니다.",
},
clipboard_btn:{
Pasted_dis:"붙여넣기 완료",
Copied_dis:"복사 완료",
},
fileUploadHandler:{
NoFileChosen_tips:"선택된 파일 없음",
fileChosen_tips_template: "{fileNum}개의 파일 및 {folderNum}개의 폴더 선택됨",
Drag_tips:"파일/폴더를 여기로 드래그 앤 드롭하거나 클릭하여 선택하세요",
chosenDiagTitle:"업로드 유형 선택",
chosenDiagDescription:"파일 또는 폴더 업로드를 선택하세요",
SelectFile_dis:"파일 선택",
SelectFolder_dis:"폴더 선택",
},
FileTransferButton:{
SavedToDisk_tips:"파일이 이미 디스크에 저장됨",
CurrentFileTransferring_tips:"파일 전송 중",
OtherFileTransferring_tips:"현재 전송이 완료될 때까지 기다려주세요",
download_tips:"파일을 다운로드하려면 클릭하세요",
Saved_dis:"저장됨",
Waiting_dis:"대기 중",
Download_dis:"다운로드",
},
FileListDisplay:{
sending_dis: '전송 중',
receiving_dis: '수신 중',
finish_dis:"완료됨",
delete_dis:"삭제",
downloadNum_dis:"다운로드 횟수",
folder_tips_template:"폴더 이름: {name} ({num}개의 파일 및 {size})",
folder_dis_template:" ({num}개의 파일, {size})",
PopupDialog_title:"권장: 저장 디렉토리 선택",
PopupDialog_description:"대용량 파일이나 폴더를 직접 디스크에 저장하는 것을 권장합니다. 이를 통해 대용량 파일 전송 및 폴더 동기화가 더 효율적으로 이루어집니다.",
chooseSavePath_tips:"대용량 파일이나 폴더를 선택한 디렉토리에 직접 저장하세요. 👉",
chooseSavePath_dis:"저장 위치 선택",
},
RetrieveMethod:{
P:"축하합니다 🎉 공유된 콘텐츠가 검색을 기다리고 있습니다:",
RoomId_tips:"방 ID 검색: ",
copyRoomId_tips:"방 ID 복사",
url_tips:"URL로 검색: ",
copyUrl_tips:"공유 URL 복사",
scanQR_tips:"QR 코드를 스캔하여 받기 👇",
Copied_dis:"복사됨",
Copy_QR_dis:"QR 코드 복사",
download_QR_dis:"QR 코드 다운로드",
},
ClipboardApp:{
fetchRoom_err:"방을 가져오지 못했습니다. 다시 시도해주세요.",
roomCheck:{//handleShareRoomCheck
empty_msg:"방 ID는 비어 있을 수 없습니다",
available_msg: '방을 사용할 수 있습니다',
notAvailable_msg: '방을 사용할 수 없습니다. 다른 방을 시도해주세요',
},
channelOpen_msg:"'데이터 채널이 열렸습니다. 데이터 수신 준비 중...'",
waitting_tips:"수신자가 연결될 때까지 기다리는 중입니다. 전송이 완료될 때까지 이 페이지를 열어 두세요. 데스크톱에서는 브라우저를 최소화하거나 탭을 전환할 수 있습니다. 모바일에서는 브라우저를 포그라운드에 유지하세요.",
joinRoom: {
EmptyMsg: "경고, 방 ID가 비어 있습니다",
DuplicateMsg: "입력한 방 ID가 중복되었습니다. 다시 입력해주세요.",
successMsg: "방에 성공적으로 입장했습니다! 전송이 완료되기 전까지 현재 페이지를 닫지 마세요. (데스크톱에서는 브라우저를 최소화하거나 탭을 전환할 수 있으며, 모바일에서는 브라우저를 백그라운드로 이동하지 마세요.)",
notExist: "참여하려는 방이 존재하지 않습니다. 보내는 사람만 방을 만들 수 있습니다.",
failMsg: "방 참여 실패:"
},
pickSaveMsg: "직접 디스크에 저장하시겠습니까?",
roomStatus: {
senderEmptyMsg: "방이 비어 있습니다",
receiverEmptyMsg: "초대를 수락하여 방에 참여할 수 있습니다",
onlyOneMsg: "현재 방에 혼자 있습니다",
peopleMsg_template: "방에 {peerCount}명이 있습니다",
connected_dis:"연결됨",
},
html:{//html 部分的消息
senderTab: "보내기",
retrieveTab: "검색",
shareTitle_dis:"콘텐츠 공유",
retrieveTitle_dis:"콘텐츠 검색",
RoomStatus_dis:"상태:",
Paste_dis:"붙여넣기",
Copy_dis:"복사",
inputRoomIdprompt: "방 ID (편집 가능):",
joinRoomBtn: "방 참여",
startSendingBtn: "전송 시작",
readClipboardToRoomId: "방 ID 붙여넣기",
enterRoomID_placeholder: "방 ID 입력",
retrieveMethod: "검색 방법",
inputRoomId_tips:"방 ID (편집 가능):",
joinRoom_dis:"방 참여",
startSending_loadingText:"전송됨",
startSending_dis:"전송 시작",
readClipboard_dis:"방 ID 붙여넣기",
retrieveRoomId_placeholder:"방 ID 입력",
RetrieveMethodTitle:"검색 방법",
}
},
home: {
h1: "무료 보안 온라인 클립보드 및 파일 전송 도구",
h1P: "등록 없이 텍스트, 이미지, 파일 및 폴더를 쉽게 공유하세요. 크기나 속도에 제한 없이 완전히 무료로 제공됩니다. 기기 간 직접적인 엔드투엔드 암호화 전송을 즐기세요.",
h2_screenOnly: '지금 보안 클립보드 및 파일 전송 도구를 사용해보세요',
h2_demo: "보안 파일 공유 작동 방식 보기",
h2P_demo: "로컬 우선, 엔드투엔드 암호화 파일 공유가 어떻게 개인 정보를 보호하는지 확인하세요",
watch_tips:"다음 플랫폼에서도 동영상을 시청할 수 있습니다:",
youtube_tips: "YouTube에서 SecureShare 보기",
bilibili_tips:"Bilibili에서 SecureShare 보기",
}
},
}
+260
View File
@@ -0,0 +1,260 @@
import { Messages } from '@/types/messages'
export const zh: Messages = {
meta: {
home: {
title: "SecureShare:免费P2P文件传输与剪贴板共享 | 隐私无上传",
description: "SecureShare提供即时安全的P2P文件传输,无大小限制,无需注册。支持文本、图片、文件夹跨设备分享,端到端加密。完美支持团队协作和私密文件传输。",
keywords: '文件共享,安全文件传输,P2P文件传输,webrtc文件共享,私密剪贴板,团队协作,跨设备共享,加密文件传输,免注册文件共享,无限制文件传输,文件夹同步,手机文件传输,安全通讯,即时文件共享,私密数据传输',
},
about: {
title: "关于SecureShare",
description: "了解SecureShare,我们致力于提供安全私密的文件传输和剪贴板共享服务,确保用户隐私和数据保护。"
},
faq: {
title: "SecureShare常见问题",
description: "查找SecureShare常见问题解答,包括如何发送文件、共享剪贴板内容以及确保数据传输安全和私密性。",
keywords: 'SecureShare常见问题,文件共享FAQ,私密数据共享帮助,端到端加密文件传输,安全剪贴板共享支持,如何使用SecureShare,文件传输问题,隐私共享问题,SecureShare故障排除',
},
help: {
title: "SecureShare帮助与支持",
description: "查看如何联系SecureShare支持团队,以及关于、使用条款和隐私政策等详细信息。"
},
privacy: {
title: "SecureShare隐私政策",
description: "了解SecureShare如何保护您的隐私和数据,包括信息收集、数据存储和安全性,以及我们不与第三方共享数据的承诺。"
},
terms: {
title: "SecureShare使用条款",
description: "查看SecureShare使用条款,包括服务使用规范、数据隐私和安全性,以及责任限制等信息。"
},
},
text: {
Header: {
Home_dis: "首页",
Blog_dis: "博客",
About_dis: "关于",
Help_dis: "帮助",
FAQ_dis: "常见问题",
Terms_dis: "条款",
Privacy_dis: "隐私",
},
Footer: {
CopyrightNotice: "SecureShare 版权所有",
Terms_dis: "使用条款",
Privacy_dis: "隐私政策",
SupportedLanguages: "支持的语言",
},
privacy: {
PrivacyPolicy_dis: "隐私政策",
h1: "SecureShare隐私政策",
h1_P: "SecureShare致力于保护您的隐私和个人信息安全。本隐私政策说明了我们如何收集、使用和保护您在使用服务时提供的数据。",
h2_1: "信息收集",
h2_1_P: "SecureShare不收集任何个人身份信息。我们不需要注册或创建账户。我们仅收集房间ID和您选择与其他用户共享的文件/剪贴板数据。",
h2_2: "数据存储和安全",
h2_2_P: "我们不在服务器上存储任何数据。所有文件传输和剪贴板共享都使用端到端加密,确保信息安全且仅供预期接收者访问。传输完成后,数据将从系统中删除。",
h2_3: "第三方服务",
h2_3_P: "SecureShare不与任何第三方服务或平台集成。我们不会与任何第三方共享或出售您的数据。",
h2_4: "隐私政策修订",
h2_4_P: "我们可能会不时更新本隐私政策以反映我们的做法或适用法律的变更。更新后的政策将在网站上发布时立即生效。请定期查看隐私政策以了解任何更新。",
h2_5: "联系我们",
h2_5_P: "如果您对我们的隐私实践有任何问题或疑虑,请联系我们:",
},
terms: {
TermsOfUse_dis: "使用条款",
h1: "SecureShare使用条款",
h1_P: "使用SecureShare服务即表示您同意遵守这些使用条款。如果您不同意这些条款,请不要使用本服务。",
h2_1: "服务使用",
h2_1_P: "SecureShare是一项免费服务,没有任何限制。",
h2_2: "数据隐私和安全",
h2_2_P: "我们非常重视您的数据隐私和安全。所有文件传输和剪贴板共享都采用端到端加密,我们不在服务器上存储任何数据。但我们无法保证传输过程中的数据安全,使用本服务需自行承担风险。",
h2_3: "可接受使用",
h2_3_P: "您同意不将SecureShare用于任何非法、滥用或有害目的。这包括但不限于传输非法、受版权保护或恶意内容,以及使用服务骚扰或冒充他人。",
h2_4: "责任限制",
h2_4_P: "SecureShare按\"原样\"提供,不提供任何保证。对于使用我们服务而导致的任何直接、间接或后果性损害,包括但不限于数据丢失、系统故障或服务中断,我们不承担责任。",
h2_5: "条款变更",
h2_5_P: "我们保留随时更新这些使用条款的权利。更新后的条款将在网站上发布时立即生效。请定期查看使用条款以了解任何变更。",
},
help: {
Help_dis: "帮助",
h1: "SecureShare帮助与支持",
h1_P: "我们随时为您提供帮助,让您充分利用SecureShare。如果您有任何问题或需要协助,请随时联系我们。",
h2_1: "联系我们",
h2_1_P1: "您可以发送邮件至",
h2_1_P2: "。我们将在24小时内回复。",
h2_2: "社交媒体",
h2_2_P: "您也可以在社交媒体上找到我们:",
h2_3: "更多资源",
h2_3_P: "关于SecureShare的更多信息,请查看以下页面:",
},
about: {
h1: "关于SecureShare",
P1: "SecureShare是一款免费且安全的文件传输和剪贴板共享工具,专注于隐私保护和易用性。我们的使命是提供一个简单但强大的解决方案,让您可以不受限制地跨设备传输文件和共享内容。",
P2: "SecureShare的核心是我们对安全和隐私的承诺。我们使用端到端加密确保您的数据在传输过程中受到保护,绝不在服务器上存储您的文件或剪贴板内容。这意味着您的数据始终保持在本地,由您完全控制。",
P3: "使用SecureShare,您可以轻松共享文本、图片和任意大小的文件,无需注册或登录。我们的平台设计注重快速、高效和环保,为您提供流畅和友好的使用体验。",
P4: "我们致力于帮助用户掌控自己的数字生活,SecureShare正是这一愿景的体现。我们希望这个工具能帮助您安全地与朋友、家人和同事共享和协作,同时不影响您的隐私或安全。",
P5: "如需更多信息或有任何问题,请访问以下页面:",
},
HowItWorks: {
h2: "使用方法",
h2_P: "三步即可实现即时文件和消息共享",
btn_try: "立即体验 →",
step1_title: "输入或选择文件",
step1_description: "输入消息或拖放文件/文件夹到选择区域",
step2_title: "加入房间",
step2_description: "点击\"加入房间\"按钮创建共享会话",
step3_title: "接收",
step3_description: "在接收页面输入房间ID并点击\"加入房间\"获取共享内容",
},
SystemDiagram: {
h2: "系统架构",
h2_P: "SecureShare:您掌控数据。简单、快速、私密。",
},
KeyFeatures: {
h2: "核心特点",
h3_1: "直接且安全",
h3_1_P: "文件直接从您的设备传输到接收方,如同一条只有你们能访问的秘密通道。通过端到端加密,您的数据就像说着只有预期接收者才能理解的语言。不想继续共享?只需关闭浏览器标签页,就像挂断电话一样简单,一切尽在掌控。",
h3_2: "团队协作",
h3_2_P: "与整个团队共享就像与一个人共享一样简单。就像主持数字圆桌会议,每个人同时接收文件。无论是创意项目协作还是重要文档分发,都像让所有人同处一室,共同接收您的共享愿景。完美适用于头脑风暴、团队展示或任何需要多人连接的场合。",
h3_3: "无限制,智能处理",
h3_3_P: "想象一条能传输任何东西的魔法管道!发送任意大小的文件,仅受磁盘空间限制。对于超大文件,可以选择保存位置。就像有一个特殊的传送服务,不会降低计算机速度 - 文件直接写入磁盘,保持设备运行流畅。",
h3_4: "快如闪电",
h3_4_P: "分分钟共享文本、图片,甚至整个文件夹。就像瞬间传送您的数字内容。需要发送整个相册或文档文件夹?轻而易举,就像分享单个文件一样简单。",
h3_5: "环保简洁",
h3_5_P: "我们就像面对面交谈的数字版本 - 不在任何地方存储内容。这意味着我们极其环保,资源消耗最小化。就像在数字世界不留痕迹,为每个人保持清洁和环保。",
},
faqs: {
FAQ_dis:"常见问题",
question_0: "数据真的是本地存储,不会传输到其他服务器吗?",
answer_0: "是的,所有数据都在本地处理。您可以查看主页上的YouTube视频 - 在建立连接后断开互联网,文件仍然可以在本地网络内传输。未来我们计划开源代码,供所有人审查。",
question_1: "如何发送和接收文件夹?",
answer_1: "发送文件夹和发送文件一样简单。将文件夹拖入文件选择区域或点击区域选择,然后点击\"开始发送\"按钮。接收方可以直接下载或在下载前选择保存目录。前者保存到内存,后者直接保存到磁盘。",
question_2: "可以更改房间ID吗?",
answer_2: "可以,您可以将房间ID更改为任何您喜欢的字符串。",
question_3: "可以持续共享内容吗?",
answer_3: "只要保持连接状态,您可以在内容变更时随时点击\"开始发送\"按钮更新共享内容。",
question_4: "可以同时与多个接收者共享文件吗?",
answer_4: "当然可以!一个人接收和多人同时接收没有任何区别。",
question_5: "使用SecureShare时我的数据安全吗?",
answer_5: "绝对安全。您的数据始终保持在本地,通过加密的端到端连接在设备间传输。所有传输的数据都经过加密,确保只有您和接收者能访问。",
question_6: "使用SecureShare需要创建账号吗?",
answer_6: "无需注册或登录,打开网站即可使用。便捷和速度是我们的首要考虑。",
question_7: "有文件大小限制吗?",
answer_7: "没有文件大小或速度限制。只要您有足够的磁盘空间,通过在下载前设置保存目录,就可以传输任意大小的文件。",
question_8: "可以同时共享多个文件或文件夹吗?",
answer_8: "可以,共享多个文件或文件夹和共享单个文件一样简单。您还可以添加文件到传输列表中,只需点击\"开始发送\"即可为接收方更新。",
question_9: "如果我改变主意,如何停止共享?",
answer_9: "停止共享非常简单,只需关闭浏览器标签页或窗口即可。这样连接就会断开,无法继续传输数据。",
question_10: "使用SecureShare会降低我的设备速度吗?",
answer_10: "不会,SecureShare设计轻量高效。如果您设置了保存目录,所有接收的数据会直接写入磁盘,绕过内存,有助于保持设备性能。",
question_11: "可以离线使用SecureShare吗?",
answer_11: "可以,如果发送方和接收方在同一个本地网络中,他们可以在连接互联网时加入同一个房间,然后断开互联网连接。文件共享仍然可以工作。具体细节可以参考主页上的YouTube视频。",
question_12: "SecureShare使用任何服务器吗?",
answer_12: "是的,确实有一个轻量级服务器,但仅用于建立加密连接的信令。一旦连接建立,所有数据都通过加密连接直接在设备之间传输。",
question_13: "房间ID的有效期是多久?",
answer_13: "房间ID的初始有效期为24小时。如果有接收者加入房间,有效期会自动从那一刻起延长24小时。",
},
clipboard_btn: {
Pasted_dis: "已粘贴",
Copied_dis: "已复制",
},
fileUploadHandler: {
NoFileChosen_tips: "未选择文件",
fileChosen_tips_template: "已选择{fileNum}个文件和{folderNum}个文件夹",
Drag_tips: "拖放文件/文件夹至此,或点击选择",
chosenDiagTitle: "选择上传类型",
chosenDiagDescription: "选择是要上传文件还是文件夹",
SelectFile_dis: "选择文件",
SelectFolder_dis: "选择文件夹",
},
FileTransferButton: {
SavedToDisk_tips: "文件已保存到磁盘",
CurrentFileTransferring_tips: "文件正在传输中",
OtherFileTransferring_tips: "请等待当前传输完成",
download_tips: "点击下载文件",
Saved_dis: "已保存",
Waiting_dis: "等待中",
Download_dis: "下载",
},
FileListDisplay: {
sending_dis: '发送中',
receiving_dis: '接收中',
finish_dis: "已完成",
delete_dis: "删除",
downloadNum_dis: "下载次数",
folder_tips_template: "文件夹名称:{name}(共{num}个文件,总大小{size}",
folder_dis_template: "{num}个文件,{size}",
PopupDialog_title: "建议:选择保存目录",
PopupDialog_description: "我们建议选择一个保存目录来直接将文件保存到磁盘。这样可以更方便地传输大文件和同步文件夹。",
chooseSavePath_tips: "大文件或文件夹可直接保存到指定目录 👉",
chooseSavePath_dis: "选择保存位置",
},
RetrieveMethod: {
P: "恭喜 🎉 共享内容等待接收:",
RoomId_tips: "接收用的房间ID是:",
copyRoomId_tips: "复制房间ID",
url_tips: "分享链接:",
copyUrl_tips: "复制分享链接",
scanQR_tips: "扫描二维码接收 👇",
Copied_dis: "已复制",
Copy_QR_dis: "复制二维码",
download_QR_dis: "下载二维码",
},
ClipboardApp: {
fetchRoom_err: "获取房间失败,请重试。",
roomCheck: {
empty_msg: "房间ID不能为空",
available_msg: '房间可用',
notAvailable_msg: '房间不可用,请尝试其他房间',
},
channelOpen_msg: "数据通道已开启,准备接收数据...",
waitting_tips: "等待接收方连接。请保持此页面打开直到传输完成。在桌面端,您可以最小化浏览器或切换标签页。在移动端,请保持浏览器在前台。",
joinRoom: {
EmptyMsg: "警告,房间ID为空",
DuplicateMsg: "您输入的房间ID重复,请重新输入。",
successMsg: "成功加入房间!在被接收之前不要关闭当前页(电脑端可以最小化浏览器或切换tab页,移动端不要将浏览器切到后台)。",
notExist: "您尝试加入的房间不存在。只有发送方可以创建房间。",
failMsg: "加入房间失败:"
},
pickSaveMsg: "直接保存到磁盘?",
roomStatus: {
senderEmptyMsg: "房间为空",
receiverEmptyMsg: "您可以接受邀请加入房间",
onlyOneMsg: "只有您一人在房间内",
peopleMsg_template: `房间内共{peerCount}人`,
connected_dis:"已连接",
},
html: {
senderTab: "发送",
retrieveTab: "接收",
shareTitle_dis:"分享内容",
retrieveTitle_dis:"接收内容",
RoomStatus_dis: "状态:",
Paste_dis: "粘贴",
Copy_dis: "复制",
inputRoomIdprompt: "您的房间ID(可编辑):",
joinRoomBtn: "加入房间",
startSendingBtn: "开始发送",
readClipboardToRoomId: "粘贴房间ID",
enterRoomID_placeholder: "输入房间ID",
retrieveMethod: "接收方式",
inputRoomId_tips: "您的房间ID(可编辑):",
joinRoom_dis: "加入房间",
startSending_loadingText: "已发送",
startSending_dis: "开始发送",
readClipboard_dis: "粘贴房间ID",
retrieveRoomId_placeholder: "输入房间ID",
RetrieveMethodTitle: "接收方式",
}
},
home: {
h1: "免费安全的在线剪贴板与文件传输工具",
h1P: "轻松共享文本、图片、文件和文件夹,享受无与伦比的隐私保护,完全免费且无需注册。无大小和速度限制。设备间直接传输,端到端加密,零成本。",
h2_screenOnly: '立即体验安全剪贴板与文件传输工具',
h2_demo: "观看安全文件共享演示",
h2P_demo: "了解我们如何通过本地优先、端到端加密的文件共享保护您的隐私",
watch_tips:"也可以在以下平台观看视频:",
youtube_tips: "在 YouTube 观看 SecureShare",
bilibili_tips:"在 Bilibili 观看 SecureShare",
},
}
}
@@ -0,0 +1,265 @@
---
title: "Browser-to-Browser Direct Connection: Unveiling the Core Technology of Privacy-Focused File Transfer Based on WebRTC"
description: "This article deep dives into the core technologies of a privacy-focused file transfer tool based on WebRTC, including P2P transmission, E2EE encryption, and stream multiplexing. Suitable for both tech enthusiasts and general users."
date: "2025-02-09"
author: "david bai"
cover: "/blog-assets/webrtc-file-transfer.jpg"
tags: [WebRTC, P2P Transfer, Privacy Security]
status: "published"
---
![](/blog-assets/webrtc-file-transfer.jpg)
## Introduction
Traditional file transfer methods largely rely on cloud storage or centralized servers, raising concerns about data privacy while facing limitations on upload sizes and speed bottlenecks. Our tool leverages WebRTC technology to enable direct device-to-device transfers, effectively addressing these challenges.
Our developed tool([<u>**SecureShare**</u>](https://www.securityshare.xyz)) features several notable characteristics:
- Device-to-device direct transfer using WebRTC technology, eliminating the need for intermediate servers
- End-to-end encryption (E2EE) ensuring secure data transmission
- No registration required, instant usage, supporting multiple simultaneous receivers
- Support for various data types including text, images, files, and folders
- Transfer speed and file size limited only by network bandwidth and disk space between devices
In this article, we'll explore the technical architecture, working principles, and why this tool can provide such a secure and efficient file transfer experience. Whether you're a tech enthusiast or a general user, you'll gain insights into how WebRTC technology is revolutionizing file transfer.
## I. Redefining File Transfer: The Architectural Revolution of WebRTC
WebRTC (Web Real-Time Communication) is an open standard supporting real-time communication between browsers. Our file transfer tool, developed based on WebRTC, comprises several core components:
1. **Signaling Server**: Coordinates connections between devices without participating in actual data transfer.
2. **P2P Connection**: Direct device-to-device connections without third-party server intervention.
3. **E2EE Encryption**: All data is end-to-end encrypted using the DTLS protocol during transmission.
### 1.1 Traditional vs WebRTC Approach
| Feature | Traditional HTTP Transfer | WebRTC P2P Transfer |
|---------|-------------------------|-------------------|
| Transfer Path | Client → Server → Client | Direct Device-to-Device |
| Latency | Limited by central server bandwidth | Limited only by physical network bandwidth |
| File Size Limit | Usually restricted | Limited only by disk space |
| Privacy Protection | Depends on service provider security | Mandatory encryption via DTLS protocol |
### 1.2 P2P Connection Establishment Process
```mermaid
sequenceDiagram
participant UserA as User A (Sender)
participant SignalingServer as Signaling Server
participant UserB as User B (Receiver)
UserA->>SignalingServer: (1) Create and join room
activate SignalingServer
UserB->>SignalingServer: (2) Join room
SignalingServer-->>UserA: Notify User B joined
UserA->>SignalingServer: (3) Send WebRTC negotiation info (SDP/ICE)
SignalingServer-->>UserB: Forward WebRTC negotiation info (SDP/ICE)
UserB->>SignalingServer: (4) Respond with WebRTC negotiation info (SDP/ICE)
SignalingServer-->>UserA: Forward response WebRTC negotiation info (SDP/ICE)
UserA-->>UserB: (5) Establish P2P connection (DataChannel)
UserA->>UserB: (6) File chunk transfer via DataChannel
```
**Process:**
1. User A creates and joins a room, connecting to the signaling server.
2. User B joins the room and connects to the signaling server.
3. User A initiates WebRTC negotiation with User B (including SDP and ICE information).
4. User B responds with WebRTC negotiation information, completing P2P connection establishment.
5. Finally, files are transferred via DataChannel over the P2P connection.
### 1.3 The Performance Magic of SCTP (over DTLS & UDP)
WebRTC's **DataChannel** is based on the **Stream Control Transmission Protocol (SCTP)** running over **DTLS** and **UDP**, offering three major advantages over traditional TCP:
1. **Stream Multiplexing (Not Currently Used)**: File chunks can be transmitted in parallel, improving transfer efficiency.
2. **No Head-of-Line Blocking**: Loss of a single chunk doesn't affect overall progress, ensuring transfer stability.
3. **Automatic Congestion Control**: Dynamically adapts to network jitter, optimizing transfer performance.
**UDP Advantages:**
- **Low Latency**: UDP is a connectionless protocol requiring no three-way handshake, ideal for real-time communication.
- **Flexible Reliability**: While UDP itself is unreliable, SCTP implements reliable transmission mechanisms on top of it, combining UDP's flexibility with TCP's reliability.
**SCTP Multi-Stream Transfer Diagram**
```mermaid
graph TD
A[Sender] --> B[DataChannel 1]
A --> C[DataChannel 2]
A --> D[DataChannel 3]
B --> E[Receiver]
C --> E
D --> E
```
## II. Browser Direct Transfer Engine: Core Technology Decoded
### 2.1 Precise Control of Chunk Transfer
```typescript
// lib/fileSender.ts - 64KB Fixed-Size Chunks
// Define chunk size as 65536 bytes (64KB) to precisely match network MTU (Maximum Transmission Unit) size.
// This prevents network congestion or fragmentation issues caused by oversized packets.
private readonly CHUNK_SIZE = 65536;
// Create an async generator function for processing files in fixed-size chunks.
// Each generator call returns an ArrayBuffer type chunk data.
private async *createChunkGenerator(file: File) {
let offset = 0; // Initialize offset to mark current file reading position
// Loop through file until all data is processed
while (offset < file.size) {
// Use File.slice method to extract data segment from [offset, offset + CHUNK_SIZE)
const chunk = file.slice(offset, offset + this.CHUNK_SIZE);
// Convert extracted data to ArrayBuffer and return via yield
yield await chunk.arrayBuffer();
// Update offset for next chunk
offset += this.CHUNK_SIZE;
}
}
// Back-pressure control algorithm: Ensures sending doesn't exceed DataChannel buffer limits.
// If buffer is full, wait until buffer space becomes available before continuing.
private async sendWithBackpressure(chunk: ArrayBuffer) {
// Pause sending when DataChannel buffer usage exceeds preset maximum
while (this.dataChannel.bufferedAmount > this.MAX_BUFFER) {
// Use Promise to wait for bufferedamountlow event indicating buffer space freed
await new Promise(r => this.dataChannel.bufferedamountlow = r);
}
// Send current chunk when buffer has sufficient space
this.dataChannel.send(chunk);
}
```
### 2.2 Zero-Copy Memory Writing
Implemented through File System Access API:
```typescript
// lib/fileReceiver.ts
// Write received chunk data directly to disk, avoiding extra memory copies
private async writeToDisk(chunk: ArrayBuffer) {
// Initialize file writer if not yet created
if (!this.writer) {
// Show save file picker dialog for user to choose save location
this.currentFileHandle = await window.showSaveFilePicker();
// Create writable stream through file handle for subsequent writes
this.writer = await this.currentFileHandle.createWritable();
}
// Convert received ArrayBuffer to Uint8Array and write directly to disk
// This bypasses memory buffer, achieving zero-copy writing for improved performance
await this.writer.write(new Uint8Array(chunk));
}
```
## III. Distributed Room Management System
### 3.1 Four-Digit Collision Detection:
```typescript
// server.ts
async function getAvailableRoomId() {
let roomId;
do {
roomId = Math.floor(1000 + Math.random() * 9000); // Generate four-digit random number
} while (await redis.hexists(`room:${roomId}`, 'created_at')); // Check if exists
return roomId;
}
```
Note: The 4-digit number is a system-generated random room ID. You can specify any room ID you prefer.
### 3.2 Graceful Expiration Strategy:
```typescript
// server.ts
await refreshRoom(roomId, 3600 * 24); // Active rooms retained for 24 hours
if (await isRoomEmpty(roomId)) { // Release room if idle (both sender and receiver left)
await deleteRoom(roomId);
}
```
### 3.3 Signaling-Driven Recovery Protocol
Mobile Disconnection Recovery Flow:
```mermaid
sequenceDiagram
participant Sender
participant Signaling
participant Recipient
Sender->>Signaling: Send initiator-online when frontend visible
Signaling->>Recipient: Forward online notification
Recipient->>Signaling: Reply recipient-ready
Signaling->>Sender: Trigger reconnection process
Sender->>Recipient: Rebuild ICE connection
```
Through this mechanism, the system can quickly restore connections even when users switch applications or enter background on mobile devices (mobile also includes Wakelock to prevent sleep), ensuring a good user experience.
## IV. Security and Privacy Defense Line
### 4.1 Encryption Protocol Flywheel
```
Application Layer
DTLS 1.2+ → TLS_ECDHE_RSA_AES_128_GCM_SHA256
OS-Level Encryption
```
**Explanation:**
1. **DTLS (Datagram Transport Layer Security)**:
- DTLS is a UDP-based secure transport protocol providing TLS-like encryption.
- In WebRTC, all data channels are end-to-end encrypted via DTLS, preventing eavesdropping or tampering during transmission.
- Uses encryption suite **`TLS_ECDHE_RSA_AES_128_GCM_SHA256`** for high-strength security.
2. **OS-Level Encryption**:
- Modern browsers provide additional protection for sensitive data in memory at the OS level, preventing malicious software access.
**Summary:**
Through dual protection of DTLS and OS-level encryption, WebRTC provides robust privacy protection ensuring data security during file transfer.
### 4.2 Attack Surface Defense Matrix
| **Attack Type** | **Defense Measure** | **Explanation** |
| --- | --- | --- |
| **MITM** | **SDP Fingerprint Verification** | **Generates unique fingerprint from DTLS public key hash to ensure communication party identity, preventing middleman data stream forgery or tampering.** |
| **RoomID Traversal Attack** | **Room Entry Rate Limiting** | **Limits room entry frequency per IP address (e.g., max 2 joins per 5 seconds) to prevent malicious users from traversing room numbers to access content.** |
**Explanation:**
1. **MITM (Man-in-the-Middle Attack)**
- **Principle**: WebRTC uses SDP fingerprints (based on DTLS public key hash) to verify communication party identity during handshake. Attackers cannot forge valid fingerprints, thus cannot impersonate legitimate parties.
- **Effect**: Ensures P2P connection security and data integrity, preventing eavesdropping or tampering.
2. **RoomID Traversal Attack**
- **Definition**: Malicious users might attempt different room numbers (e.g., four-digit IDs) to enter unauthorized rooms and access shared content.
- **Defense Measures**:
- **Rate Limiting**: Restrict room entry frequency per IP address, e.g., max 2 room joins per 5 seconds.
- **Implementation**: Use Redis to cache IP request records for quick detection and blocking of abnormal behavior.
- **Effect**: Effectively prevents malicious users from accessing sensitive content through room number traversal, protecting user privacy.
## Conclusion: Building Trustworthy Transfer Infrastructure
We believe technology should serve essential human needs rather than create new surveillance dependencies. Experience this privacy-secure file transfer tool now and feel the revolutionary changes brought by P2P technology! Click [<u>**SecureShare Portal**</u>](https://www.securityshare.xyz) to begin.
**Code Transparency Commitment**: Code will be open-sourced in the future. We are committed to establishing truly trustworthy privacy tools through community co-governance.
## FAQ
- **Will large file transfers be prone to interruption?**
- Haven't observed such cases yet. P2P (device-to-device) connections are generally stable. We may add resume-from-breakpoint capability based on future feedback.
- **Would adding room passwords be more secure?**
- Theoretically yes. Considering password addition would slightly impact usability, it's not implemented yet. For enhanced security, you can use any custom string as RoomID and share via links and QR codes. Additionally, the system limits receiver room entry frequency, further improving security.
- **Can senders close the SecureShare page anytime?**
- Yes, preferably after content is received. Since it's direct device connection, sharing isn't possible if sender is offline. If you want to stop sharing, you can close the page immediately.
More questions? Click [<u>**SecureShare FAQ**</u>](https://www.securityshare.xyz/faq) or [<u>**SecureShare Help**</u>](https://www.securityshare.xyz/help) sections for more answers and help.
**Developer Resources**
- [<u>**WebRTC Official Documentation**</u>](https://webrtc.org/)
@@ -0,0 +1,256 @@
---
title: "纯浏览器端直连!揭秘基于 WebRTC 的隐私安全文件传输核心技术"
description: "本文深入探讨基于 WebRTC 的隐私安全文件传输工具的核心技术,包括 P2P 传输、E2EE 加密、多流复用等,适合技术爱好者和普通用户阅读。"
date: "2025-02-09"
author: "david bai"
cover: "/blog-assets/webrtc-file-transfer.jpg"
tags: [WebRTC, P2P传输, 隐私安全]
status: "published"
---
![](/blog-assets/webrtc-file-transfer.jpg)
## 引言
传统的文件传输方式大多依赖云存储或中心化服务器,这不仅带来了数据隐私的顾虑,还可能面临上传大小限制、速度瓶颈等诸多问题。而我们的工具通过 WebRTC 技术实现了设备间的直接传输,彻底解决了这些问题。
我们开发的这款工具([<u>**SecureShare**</u>](https://www.securityshare.xyz))具有以下突出特点:
- 采用WebRTC技术实现设备间直接传输,无需经过中间服务器
- 端到端加密(E2EE)确保数据传输安全
- 无需注册登录,即开即用,可多人同时接收
- 支持文本、图片、文件、文件夹等多种类型的数据传输
- 传输速度和文件大小仅受限于设备间的网络带宽和磁盘空间
在这篇文章中,我们将深入探讨这款工具的技术架构、工作原理以及它为什么能够提供如此安全和高效的文件传输体验。无论您是技术爱好者还是普通用户,都能从中了解到WebRTC技术在文件传输领域带来的革命性变化。
## 一、重新定义文件传输:WebRTC 的架构革命
WebRTCWeb Real-Time Communication)是一种支持浏览器之间实时通信的开放标准。我们的文件传输工具基于WebRTC开发,主要包含以下几个核心组件:
1. **信令服务器**:用于协调设备之间的连接,但不参与实际数据传输。
2. **P2P连接**:设备之间直接建立连接,数据不经过第三方服务器。
3. **E2EE加密**:所有数据在传输过程中使用DTLS协议进行端到端加密。
### 1.1 传统方案 vs WebRTC 方案
| 特性 | 传统 HTTP 传输 | WebRTC P2P 传输 |
|--------------------|-------------------------|-------------------------|
| 传输路径 | 客户端→服务器→客户端 | 端到端直连 |
| 延时特性 | 受中心服务器带宽限制 | 仅受网络物理带宽限制 |
| 文件大小限制 | 通常有一定的限制 | 仅受磁盘空间限制 |
| 隐私保护 | 依赖服务商安全措施 | DTLS 协议强制加密 |
### 1.2 建立P2P连接的流程
```mermaid
sequenceDiagram
participant UserA as 用户A(发送方)
participant SignalingServer as 信令服务器
participant UserB as 用户B(接收方)
UserA->>SignalingServer: (1) 创建房间并加入
activate SignalingServer
UserB->>SignalingServer: (2) 加入房间
SignalingServer-->>UserA: 通知用户B已加入房间
UserA->>SignalingServer: (3) 发送WebRTC协商信息(SDP/ICE)
SignalingServer-->>UserB: 转发WebRTC协商信息(SDP/ICE)
UserB->>SignalingServer: (4) 响应WebRTC协商信息(SDP/ICE)
SignalingServer-->>UserA: 转发响应WebRTC协商信息(SDP/ICE)
UserA-->>UserB: (5) 建立P2P连接(DataChannel)
UserA->>UserB: (6) 文件分块传输通过DataChannel
```
**流程:**
1. 用户A创建房间并加入,连接到信令服务器。
2. 用户B加入房间后,也连接到信令服务器。
3. 用户A开始与用户B发起 WebRTC 协商(包括 SDP 和 ICE 信息)。
4. 用户B响应 WebRTC 协商信息,完成 P2P 连接的建立。
5. 最终,文件通过 DataChannel 在 P2P 连接上传输。
### 1.3 SCTP(over DTLS & UDP) 协议的性能魔法
WebRTC 的 **DataChannel** 基于 **流控制传输协议 (SCTP)** ,运行在 **DTLS** 和 **UDP** 之上,相较于传统 TCP 具有三大优势:
1. **多流复用(暂未采用)** :文件分片可并行传输,提升传输效率。
2. **无队头阻塞** :单个分片丢失不会影响整体进度,确保传输稳定性。
3. **自动拥塞控制** :动态适应网络抖动,优化传输性能。
**UDP 的优势:**
- **低延迟** :UDP 是一种无连接协议,无需建立三次握手,适合实时通信场景。
- **灵活可靠** :虽然 UDP 本身不可靠,但 SCTP 在其基础上实现了可靠的传输机制,结合了 UDP 的灵活性和 TCP 的可靠性。
**SCTP 多流传输示意图**
```mermaid
graph TD
A[发送方] --> B[DataChannel 1]
A --> C[DataChannel 2]
A --> D[DataChannel 3]
B --> E[接收方]
C --> E
D --> E
```
## 二、浏览器直传引擎:核心技术解密
### 2.1 分片传输的精密控制
```typescript
// lib/fileSender.ts - 64KB 定长分片
// 定义每个分片的大小为 65536 字节(即 64KB),这是为了精确匹配网络 MTU(最大传输单元)尺寸。
// 这样可以避免因数据包过大而导致的网络拥塞或分片问题。
private readonly CHUNK_SIZE = 65536;
// 创建一个异步生成器函数,用于将文件按固定大小分片处理。
// 每次调用生成器时,返回一个 ArrayBuffer 类型的分片数据。
private async *createChunkGenerator(file: File) {
let offset = 0; // 初始化偏移量,用于标记当前读取到文件的位置。
// 循环读取文件,直到所有数据都被分片处理完毕。
while (offset < file.size) {
// 使用 File.slice 方法从文件中截取一段数据,范围为 [offset, offset + CHUNK_SIZE)。
const chunk = file.slice(offset, offset + this.CHUNK_SIZE);
// 将截取的数据转换为 ArrayBuffer,并通过 yield 返回给调用者。
yield await chunk.arrayBuffer();
// 更新偏移量,准备处理下一个分片。
offset += this.CHUNK_SIZE;
}
}
// 背压控制算法:确保发送数据时不会超过 DataChannel 的缓冲区限制。
// 如果缓冲区已满,则等待缓冲区可用后再继续发送。
private async sendWithBackpressure(chunk: ArrayBuffer) {
// 当 DataChannel 的缓冲区占用量超过预设的最大值时,暂停发送。
while (this.dataChannel.bufferedAmount > this.MAX_BUFFER) {
// 使用 Promise 等待 bufferedamountlow 事件触发,表示缓冲区空间已释放。
await new Promise(r => this.dataChannel.bufferedamountlow = r);
}
// 缓冲区有足够空间后,发送当前分片数据。
this.dataChannel.send(chunk);
}
```
### 2.2 内存零拷贝写入
通过 File System Access API 实现:
```typescript
// lib/fileReceiver.ts
// 将接收到的分片数据直接写入磁盘,避免内存中的额外拷贝操作。
private async writeToDisk(chunk: ArrayBuffer) {
// 如果尚未初始化文件写入器(writer),则需要先创建一个文件句柄并获取写入器。
if (!this.writer) {
// 使用 showSaveFilePicker 弹出文件保存对话框,让用户选择保存位置。
this.currentFileHandle = await window.showSaveFilePicker();
// 通过文件句柄创建一个可写流(WritableStream),用于后续写入操作。
this.writer = await this.currentFileHandle.createWritable();
}
// 将接收到的 ArrayBuffer 数据转换为 Uint8Array 格式,并直接写入磁盘。
// 这种方式绕过了内存缓冲区,实现了零拷贝写入,提升了性能。
await this.writer.write(new Uint8Array(chunk));
}
```
## 三、分布式房间管理系统
### 3.1 四位数字碰撞检测:
碰撞检测 :通过循环检查 Redis 中是否存在相同的房间号,避免重复。
```typescript
// server.ts
async function getAvailableRoomId() {
let roomId;
do {
roomId = Math.floor(1000 + Math.random() * 9000); // 生成四位随机数
} while (await redis.hexists(room:${roomId}, 'created_at')); // 检查是否已存在
return roomId;
}
```
ps:4位数字是系统生成的一个随机房间ID,你也可以指定任意自己喜欢的房间ID.
### 3.2 优雅过期策略:
优雅过期 :活跃房间会自动延长过期时间,而空闲房间会被及时清理,节省资源。
```typescript
// server.ts
await refreshRoom(roomId, 3600 * 24); // 活跃房间保留24小时
if (await isRoomEmpty(roomId)) { // 如果房间空闲(发送方、接收方都退出了),则释放房间
await deleteRoom(roomId);
}
```
### 3.3 信令驱动的重生协议
移动端断线恢复流程:
```mermaid
sequenceDiagram
participant Sender
participant Signaling
participant Recipient
Sender->>Signaling: 前端可见时发 initiator-online
Signaling->>Recipient: 转发上线通知
Recipient->>Signaling: 回复recipient-ready
Signaling->>Sender: 触发重连流程
Sender->>Recipient: 重建 ICE 连接
```
通过这一机制,即使用户在移动设备上切换应用或进入后台,系统也能快速恢复连接(移动端也加入了Wakelock防休眠),确保良好的用户体验。
## 四、安全隐私防线
### 4.1 加密协议飞轮
```
应用层
DTLS 1.2+ → TLS_ECDHE_RSA_AES_128_GCM_SHA256
操作系统级加密
```
**解释:**
1. **DTLSDatagram Transport Layer Security**
- DTLS 是基于 UDP 的安全传输协议,提供类似于 TLS 的加密功能。
- 在 WebRTC 中,所有数据通道(DataChannel)都通过 DTLS 进行端到端加密,确保数据在传输过程中无法被窃听或篡改。
- 使用的加密套件为 **`TLS_ECDHE_RSA_AES_128_GCM_SHA256`**,提供高强度的安全性。
2. **操作系统级加密**
- 在操作系统层面,现代浏览器会对内存中的敏感数据进行额外保护,防止恶意软件访问。
**总结:**
通过 DTLS 和操作系统级加密的双重保障,WebRTC 提供了强大的隐私保护能力,确保文件传输过程中的数据安全。
### 4.2 攻击面防御矩阵
| **攻击类型** | **防御措施** | **解释** |
| --- | --- | --- |
| **MITM** | **SDP 指纹校验** | **通过 DTLS 公钥哈希值生成唯一指纹,确保通信双方身份可信,防止中间人伪造或篡改数据流。** |
| **房间号遍历攻击** | **进入房间速率限制** | **对每个 IP 地址的进入房间频率进行限制(如 5 秒内最多加入 2 次),防止恶意用户遍历房间号获取内容。** |
**解释:**
1. **MITM(中间人攻击)**
- **原理** WebRTC 使用 SDP 指纹(基于 DTLS 公钥哈希值)在握手过程中验证通信双方的身份。攻击者无法伪造合法的指纹,因此无法伪装成合法通信方。
- **作用** :确保 P2P 连接的安全性和数据完整性,防止数据被窃听或篡改。
2. **房间号遍历攻击**
- **定义** :恶意用户可能通过暴力尝试不同的房间号(例如四位数字 ID),试图进入未授权的房间并获取分享内容。
- **防御措施**
- **速率限制** :对每个 IP 地址的进入房间频率进行限制,例如 5 秒内最多允许加入 2 次房间。
- **实现方式** :使用 Redis 缓存每个 IP 的请求记录,快速检测和阻止异常行为。
- **作用** :有效防止恶意用户通过遍历房间号获取敏感内容,保护用户隐私。
## 结语:打造可信赖的传输基础设施
我们坚信技术应该服务于人的本质需求,而非制造新的监控依赖。立即体验这款隐私安全的文件传输工具,感受 P2P 技术带来的革命性变化!点击[<u>**SecureShare入口**</u>](https://www.securityshare.xyz)开始 。
**代码透明度承诺**:代码未来会开源,我们致力于通过社区共治建立真正值得信赖的隐私工具。
## 常见问题
- **传大文件会不会容易中断?**
- 暂时没发现这种情况。由于是P2P(设备间)连接,一般比较稳定。后面可以看反馈情况择机加入断点续传。
- **房间加上密码更安全?**
- 理论上是的。考虑到加密码易用性上体验会差点,暂时没有加。如果想提高安全性,可以自定义任意长度的字符串作为RoomID,同时通过链接和二维码进行分享。另外,系统对接收方进入房间的频率进行了限制,此举进一步提高了安全性。
- **发送方可以随时关闭SecureShare页面吗?**
- 可以,最好在分享内容被接收之后再关闭。因为是设备间直接连接,发送方如果不在线,则无法进行分享。如果不想分享了,可以立即关闭页面。
还有问题?请点击查看[<u>**SecureShare FAQ**</u>](https://www.securityshare.xyz/faq)或[<u>**SecureShare Help**</u>](https://www.securityshare.xyz/help)部分。
**开发者资源**
- [<u>**WebRTC 官方文档**</u>](https://webrtc.org/)
+16
View File
@@ -0,0 +1,16 @@
'use client';
//获取当前语言bn
import { usePathname } from 'next/navigation';
import { i18n } from '@/constants/i18n-config'
export function useLocale() {
const pathname = usePathname();
const locale = pathname?.split('/')[1];
// 验证是否为支持的语言
if (locale && i18n.locales.includes(locale as any)) {
return locale;
}
return i18n.defaultLocale;
}
+99
View File
@@ -0,0 +1,99 @@
//博客工具函数
import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'
const POSTS_PATH = path.join(process.cwd(), 'content/blog')
export interface BlogPost {
slug: string
frontmatter: {
title: string
description: string
date: string
author: string
cover: string
tags: string[] // 直接使用 tags 数组
status: string
}
content: string
}
export async function getAllPosts(lang: string): Promise<BlogPost[]> {
const files = fs.readdirSync(POSTS_PATH)
const posts = await Promise.all(
files
.filter((file) => /\.mdx?$/.test(file))
.map(async (file) => {
const filePath = path.join(POSTS_PATH, file)
const source = fs.readFileSync(filePath, 'utf8')
const { data, content } = matter(source)
// 验证和转换 frontmatter 数据
const frontmatter = {
title: data.title ?? '',
description: data.description ?? '',
date: data.date ?? new Date().toISOString(),
author: data.author ?? '',
cover: data.cover ?? '',
tags: Array.isArray(data.tags) ? data.tags : [], // 直接使用 tags 数组
status: data.status ?? 'draft'
}
return {
slug: file.replace(/\.mdx?$/, ''),
frontmatter,
content
} as BlogPost
})
)
// 过滤掉 draft 状态的博客
return posts
.filter(post => post.frontmatter.status === 'published') // 仅保留 published 状态
.filter(post => {
// 将 slug 按 '-' 分割成数组
const parts = post.slug.split('-');
// 获取最后一部分
const lastPart = parts[parts.length - 1];
// 判断最后一部分是否等于目标语言 && 目标语言如果是中文则返回中文博客否则返回英文
const lang_dst = lang === 'zh' ? 'zh':'en';
return lastPart === lang_dst;
})
.sort((a, b) =>
new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime()
)
}
export async function getPostBySlug(slug: string): Promise<BlogPost | null> {
try {
const filePath = path.join(POSTS_PATH, `${slug}.mdx`)
const source = fs.readFileSync(filePath, 'utf8')
const { data, content } = matter(source)
// 验证和转换 frontmatter 数据
const frontmatter = {
title: data.title ?? '',
description: data.description ?? '',
date: data.date ?? new Date().toISOString(),
author: data.author ?? '',
cover: data.cover ?? '',
tags: Array.isArray(data.tags) ? data.tags : [],
status: data.status ?? 'draft'
}
return {
slug,
frontmatter,
content
}
} catch (error) {
return null
}
}
// 根据标签获取博客文章
export async function getPostsByTag(tag: string,lang: string): Promise<BlogPost[]> {
const allPosts = await getAllPosts(lang)
return allPosts.filter(post => post.frontmatter.tags.includes(tag))
}
+17
View File
@@ -0,0 +1,17 @@
//语言字典加载器
import { supportedLocales, i18n } from '@/constants/i18n-config';
export async function getDictionary(locale: string) {
try {
if (!supportedLocales.includes(locale as any)) {
console.warn(`Unsupported locale: ${locale}, falling back to default locale.`);
locale = i18n.defaultLocale;
}
const messagesModule = await import(`@/constants/messages/${locale}`);
const messages = messagesModule[locale]; // 根据语言代码获取导出的对象
return messages;
} catch (error) {
console.error(`Failed to load dictionary for locale: ${locale}`, error);
throw error;
}
}
+384
View File
@@ -0,0 +1,384 @@
//接收文件(夹)的流程:先批量接收文件 meta 信息,【判断是否需要让用户选择保存目录】,然后点击请求,再接收文件内容,接收到endMeta之后,发送ack,结束
//发送文件夹的流程(同上):接收批量文件请求
import {SpeedCalculator} from '@/lib/myUtils';
import WebRTC_Recipient from './webrtc_Recipient';
import {
CustomFile,
fileMetadata,
WebRTCMessage,
FolderProgress,
CurrentString,
StringMetadata,
StringChunk,
FileEnd,
FileHandlers,
FileMeta
} from '@/lib/types/file';
class FileReceiver {
private webrtcConnection: WebRTC_Recipient;
private readonly largeFileThreshold: number;
private currentFileMeta: fileMetadata | null;
private currentFolderName: string | null;
private currentFileChunks: (ArrayBuffer | null)[];
private writeStream: FileSystemWritableFileStream | null;
private currentFileHandle: FileSystemFileHandle | null;
private folderProgresses: Record<string, FolderProgress>;
public saveType: Record<string, boolean>;
private saveDirectory: FileSystemDirectoryHandle | null;
private pendingFilesMeta: Map<string, fileMetadata>;
private fileReceiveDone: boolean;
public onFileMetaReceived: ((meta: fileMetadata) => void) | null;
public onStringReceived: ((str: string) => void) | null;
private progressCallback: ((id: string, progress: number, speed: number) => void) | null;
public onFileReceived: ((file: CustomFile) => Promise<void>) | null;
private speedCalculator: SpeedCalculator;
private peerId: string;
private readonly chunkSize: number;
private currentString: CurrentString | null;
private fileHandlers: FileHandlers;
constructor(WebRTC_recipient: WebRTC_Recipient) {
this.webrtcConnection = WebRTC_recipient;
this.largeFileThreshold = 1 * 1024 * 1024 * 1024; // 1 * 1GB,如果大于这个阈值,则需要用户选择保存目录直接储存在磁盘
// 当前接收状态
this.currentFileMeta = null;//有meta信息--表示当前正在接收该文件,为null--当前没有接收文件
this.currentFolderName = null;//有name--表示当前正在接收文件夹,为null--当前没有接收文件夹
this.currentFileChunks = [];//接收的(存放内存的)文件块
this.writeStream = null;//写入磁盘相关对象
this.currentFileHandle = null;//写入磁盘相关对象--当前文件
this.folderProgresses = {};// 文件夹 进度信息, fileId:{totalSize:0,receivedSize:0,fileIds:[]};
this.saveType = {};//fileId:IsSaveToDisk,表示已接收到的文件是存储在磁盘还是内存
// 存储目录
this.saveDirectory = null;
// 待接收文件管理--用来展示
this.pendingFilesMeta = new Map(); // 存储文件元信息,fileId:meta
this.fileReceiveDone = false;//当前文件是否接收处理完
// 回调函数
this.onFileMetaReceived = null;
this.onStringReceived = null;
this.progressCallback = null;
this.onFileReceived = null;//接收到文件的回调,由上层区分属于哪个文件夹
// 创建 SpeedCalculator 实例
this.speedCalculator = new SpeedCalculator();
this.peerId = '';//唯一一个连接方
this.chunkSize = 65536; // 64 KB chunks
this.currentString = null;
this.setupDataHandler();
this.fileHandlers = {
string: this.handleReceivedStringChunk.bind(this),
stringMetadata: this.handleStringMetadata.bind(this),
fileMeta: this.handleFileMetadata.bind(this),
fileEnd: this.handleFileEnd.bind(this),
};
}
private setupDataHandler(): void {
this.webrtcConnection.onDataReceived = this.handleReceivedData.bind(this);
}
public setProgressCallback(callback: (fileId: string, progress: number, speed: number) => void): void {
this.progressCallback = callback;
}
private async handleReceivedData(data: string | ArrayBuffer, peerId: string): Promise<void> {
if (typeof data === 'string') {
try {
const parsedData = JSON.parse(data) as WebRTCMessage;
const handler = this.fileHandlers[parsedData.type as keyof FileHandlers];
if (handler) {
await handler(parsedData, peerId);
}
} catch (error) {
console.error('Error parsing JSON:', error);
}
} else if (data instanceof ArrayBuffer) {
this.updateProgress(data.byteLength);//更新 进度
await this.handleFileChunk(data);//接收数据
}
}
private async handleFileMetadata(metadata: fileMetadata, peerId: string): Promise<void> {
this.peerId = peerId;
console.log('fileMeta',metadata);
if(this.pendingFilesMeta.has(metadata.fileId))return;//如果已经接收过,则忽略
this.pendingFilesMeta.set(metadata.fileId, metadata);//fileId:meta
this.onFileMetaReceived?.(metadata);
//把属于folder的部分关于文件大小的记录下来,用于计算进度
const folderName = metadata.folderName;
if (folderName){
const fileId = folderName;
if (!(fileId in this.folderProgresses)){//初始化
this.folderProgresses[fileId] = {totalSize:0,receivedSize:0,fileIds:[]};//fileId:{totalSize:0,receivedSize:0,fileIds:[]};
}
const folderProgress = this.folderProgresses[fileId];
if (!folderProgress.fileIds.includes(metadata.fileId)) {//防止重复计算
folderProgress.totalSize += metadata.size;
folderProgress.fileIds.push(metadata.fileId);
}
}
}
//同步 文件夹 进度--包含回调
private syncFolderProgress(fileId: string, bytesReceived: number): void {
const folderProgress = this.folderProgresses[fileId];
if (!folderProgress) return;
folderProgress.receivedSize += bytesReceived;
this.speedCalculator.updateSendSpeed(this.peerId, folderProgress.receivedSize); // 使用累计接收量
const speed = this.speedCalculator.getSendSpeed(this.peerId);
const progress = folderProgress.receivedSize / folderProgress.totalSize;
this.progressCallback?.(fileId, progress, speed);
}
//更新 进度 并回调
private async updateProgress(byteLength: number): Promise<void> {
if (!this.peerId || !this.currentFileMeta) return;
const fileId = this.currentFolderName ? this.currentFolderName : this.currentFileMeta.fileId;
if (this.currentFolderName) {
this.syncFolderProgress(fileId, byteLength);//接收文件夹,只回传总进度
} else {
const received = this.currentFileChunks.length * this.chunkSize;
this.speedCalculator.updateSendSpeed(this.peerId, received); // 使用累计接收量
const speed = this.speedCalculator.getSendSpeed(this.peerId);
this.progressCallback?.(fileId , received/this.currentFileMeta.size, speed);//同步 单文件 进度
}
}
private handleStringMetadata(metadata: StringMetadata, peedId: string): void {
this.currentString = {
length: metadata.length,
chunks: [],
receivedChunks: 0
};
// console.log("handleStringMetadata",this.currentString);
}
private handleReceivedStringChunk(data: StringChunk, peerId: string) {
if (this.currentString) {
this.currentString.chunks[data.index] = data.chunk;
this.currentString.receivedChunks++;
// console.log("handleReceivedStringChunk",this.currentString,data.total);
if (this.currentString.receivedChunks === data.total) {
const fullString = this.currentString.chunks.join('');
// console.log("fullString",this.onStringReceived);
this.onStringReceived?.(fullString);
this.currentString = null;
}
}
}
private async handleFileEnd(metadata: FileEnd): Promise<void> {
console.log('handleFileEnd,metadata',metadata);
const file = this.pendingFilesMeta.get(metadata.fileId);
if (file) {
if (!this.currentFolderName) {//接收单独的文件时,回传进度
this.progressCallback?.(file.fileId, 1, 0);
}
await this.finalizeFileReceive();//接收完--处理
this.sendFileAck(file.fileId);//文件接收完毕 -- 发ack信号
this.fileReceiveDone = true;//当前文件接收处理完
console.log('handleFileEnd,sendFileAck');
}
}
private async finalizeFileReceive(): Promise<void> {
if (!this.currentFileMeta) return;
const fileId = this.currentFolderName;
if (this.currentFileHandle) { //(已经选择过目录 直接保存到磁盘
await this.finalizeLargeFileReceive();//磁盘文件 完成终止
} else {
const fileBlob = new Blob(this.currentFileChunks as ArrayBuffer[], { type: this.currentFileMeta.fileType });
const file = new File([fileBlob], this.currentFileMeta.name, { type: this.currentFileMeta.fileType });
this.saveType[this.currentFileMeta.fileId] = false;//存放在内存
if(fileId)this.saveType[fileId] = false;//对应的文件夹也存放在内存
const customFile = Object.assign(file, { fullName: this.currentFileMeta.fullName ,folderName: this.currentFolderName as string});
// console.log('finalizeFileReceive',customFile);
await this.onFileReceived?.(customFile);
}
//如果是接收文件夹状态,则检查是不是最后一个文件,如果不是,则新建下一个文件的磁盘流
if (this.currentFolderName && this.folderProgresses[fileId as string]) {
const folderProgress = this.folderProgresses[fileId as string];
const curIdx = folderProgress.fileIds.indexOf(this.currentFileMeta.fileId);
const isLastFileInFolder = curIdx === folderProgress.fileIds.length - 1;
this.resetFileReceiveState();//重置状态
if(!isLastFileInFolder){
const nextFileId = folderProgress.fileIds[curIdx + 1];
const nextFileMeta = this.pendingFilesMeta.get(nextFileId);
if (nextFileMeta) {
this.currentFileMeta = nextFileMeta;
if(this.saveDirectory)//如果选择过保存目录
await this.creatDiskWriteStream(this.currentFileMeta);//根据当前fileMeta创建磁盘流
}
}
} else {
this.resetFileReceiveState();
}
}
//重置 文件接收 状态
private resetFileReceiveState(): void {
this.currentFileMeta = null;
this.currentFileChunks = [];
this.currentFileHandle = null;
}
// 请求开始接收文件
public async requestFile(fileId: string, singleFile = true): Promise<void> {
if(fileId in this.saveType && this.saveType[fileId])return;//已经请求过 & 并且保存在磁盘,不重复请求
if (singleFile)this.currentFolderName = null;//不是在请求文件夹
const fileInfo = this.pendingFilesMeta.get(fileId);
// console.log('requestFile,fileInfo',fileInfo);
if (!fileInfo) return;
this.currentFileMeta = fileInfo;//当前正在接收的文件
this.fileReceiveDone = false;//当前文件 没有 接收处理完
if (this.saveDirectory || fileInfo.size >= this.largeFileThreshold || this.currentFolderName) {//需要存储
await this.creatDiskWriteStream(this.currentFileMeta);//存放在磁盘
} else {
this.currentFileChunks = [];
}
// console.log('send fileRequest,this.peerId',this.peerId);
const request = JSON.stringify({ type: 'fileRequest', fileId });
if (this.peerId) {
this.webrtcConnection.sendData(request, this.peerId);
}
// 如果当前正在传输文件,则等待传输完成--等发送fileAck
await this.waitForTransferComplete();
}
private async waitForTransferComplete(): Promise<void> {
while (!this.fileReceiveDone) {
await new Promise(resolve => setTimeout(resolve, 50));
}
}
// 请求开始接收文件夹,上层来区分 回传的文件 是否属于文件夹
public async requestFolder(folderName: string): Promise<void> {
const receivedFileIds = Object.keys(this.saveType);
const received = receivedFileIds.some(fileId => {//至少有一个满足
const fileMeta = this.pendingFilesMeta.get(fileId);
return fileMeta?.folderName === folderName && this.saveType[fileMeta.fileId];
});
console.log('requestFolder,received',received);
if(received)return;//已经请求过 & 已经保存到磁盘,不重复请求
const fileId = folderName;
const folderProgress = this.folderProgresses[fileId];
if (folderProgress) {
this.currentFolderName = folderName;//请求文件夹
for (const fileId of folderProgress.fileIds) {
await this.requestFile(fileId, false);
}
this.currentFolderName = null;//请求文件夹结束--清空标注
}
}
//接收文件 处理
private async handleFileChunk(chunk: ArrayBuffer): Promise<void> {
if (this.currentFileHandle) {
await this.writeLargeFileChunk(chunk);//保存到磁盘
} else {
this.currentFileChunks.push(chunk);//保存到内存
}
}
private sendFileAck(fileId: string): void {
if (!this.peerId) return;
const confirmation = JSON.stringify({
type: 'fileAck',
fileId
});
this.webrtcConnection.sendData(confirmation, this.peerId);
}
private async createFolderStructure(fullName: string): Promise<FileSystemDirectoryHandle> {
if (!this.saveDirectory) {
throw new Error('Save directory not set');
}
const parts_rela_path = fullName.split('/'); // 根据斜杠分割路径
parts_rela_path.pop(); // 移除最后一个元素(文件名)
console.log('createFolderStructure',fullName,parts_rela_path);
let currentPath = this.saveDirectory;
for (const part of parts_rela_path) {
if (part) {
currentPath = await currentPath.getDirectoryHandle(part, { create: true });
}
}
return currentPath;
}
public async setSaveDirectory(directory: FileSystemDirectoryHandle): Promise<void> {
this.saveDirectory = directory;
}
//建立磁盘写入流,存在保存目录的情况,否则还是保存在内存中
public async creatDiskWriteStream(meta: FileMeta): Promise<void> {
if (!this.saveDirectory) {
console.log('Save directory not set');
this.currentFileChunks = [];
} else {
try {
const folderStructure = await this.createFolderStructure(meta.fullName);
this.currentFileHandle = await folderStructure.getFileHandle(meta.name, { create: true });
this.writeStream = await this.currentFileHandle.createWritable();
} catch (err) {
console.error('Failed to create file:', err);
console.log('Falling back to in-memory storage for large file');
this.currentFileChunks = [];
}
}
}
//保存文件到磁盘
private async writeLargeFileChunk(chunk: ArrayBuffer): Promise<void> {
if (!this.writeStream) {//用户没有授权的情况,保存到内存
this.currentFileChunks.push(chunk);
return;
}
try {
await this.writeStream.write(chunk);//写入磁盘
this.currentFileChunks.push(null); // Just to keep track of the number of chunks
} catch (error) {
console.error('Error writing chunk:', error);
}
}
//磁盘文件 完成终止
private async finalizeLargeFileReceive(): Promise<void> {
if (this.writeStream) {
try {
await this.writeStream.close();
} catch (error) {
console.error('Error closing write stream:', error);
}
}
if (this.currentFileHandle && this.currentFileMeta) {//存在磁盘写入
const file = await this.currentFileHandle.getFile();//与发送端一样,只是拿到了磁盘文件的一个引用,不占用内存
const customFile = Object.assign(file, { fullName: this.currentFileMeta.fullName ,folderName: this.currentFolderName as string});
this.saveType[this.currentFileMeta.fileId] = true;//存放在磁盘
if (!this.currentFolderName) {//如果当前处于接收文件夹的状态 & 写入磁盘了,则不回传文件,不支持下载
await this.onFileReceived?.(customFile);
} else{
this.saveType[this.currentFolderName] = true;//对应的文件夹也存放在磁盘
}
}
}
}
export default FileReceiver;
+333
View File
@@ -0,0 +1,333 @@
//发送文件(夹)的流程:先发送文件 meta 信息,等待接收端请求,再发送文件内容,文件发送完再发送endMeta,等待接收端ack,结束
//发送文件夹的流程(同上):接收批量文件请求
//循环发送所有文件的meta,然后把属于folder的部分关于文件大小的记录下来,用于计算进度。接收展示端来区分单文件和文件夹
import {SpeedCalculator,generateFileId} from '@/lib/myUtils';
import WebRTC_Initiator from './webrtc_Initiator';
import {
CustomFile,
fileMetadata,
WebRTCMessage,
PeerState,
FolderMeta,
FileRequest
} from '@/lib/types/file';
class FileSender {
private webrtcConnection: WebRTC_Initiator;
private peerStates: Map<string, PeerState>;
private readonly chunkSize: number;
private readonly maxBufferSize: number;
private pendingFiles: Map<string, CustomFile>;
private pendingFolerMeta: Record<string, FolderMeta>;
private speedCalculator: SpeedCalculator;
constructor(WebRTC_initiator: WebRTC_Initiator) {
this.webrtcConnection = WebRTC_initiator;
// 为每个接收方维护独立的发送状态
this.peerStates = new Map(); // Map<peerId, PeerState>
this.chunkSize = 65536; // 64 KB chunks
this.maxBufferSize = 5; // 预读取的块数
this.pendingFiles = new Map();//所有待发送的文件(引用){fileId:CustomFile}
this.pendingFolerMeta = {};//文件夹对应的meta属性(总大小、文件总个数),用于记录传输进度,fileId:{totalSize:0 , fileIds:[]}
// 创建 SpeedCalculator 实例
this.speedCalculator = new SpeedCalculator();
this.setupDataHandler();
}
// 初始化新接收方的状态
private getPeerState(peerId: string): PeerState {
if (!this.peerStates.has(peerId)) {
this.peerStates.set(peerId, {
isSending: false,//用来判断文件是否发送成功,发送前是 true, 发送完接收到ack是 false
bufferQueue: [],//预读取buffer,提高发送效率
readOffset: 0,//读取位置,发送函数用
isReading: false,//是否正在读取,发送函数用,避免重复读取
currentFolderName: '',//如果当前发送的文件属于文件夹,则赋 文件夹名
totalBytesSent:{},//文件(夹)已发送字节数,用于计算进度;{fileId:0}
progressCallback: null,//进度回调
});
}
return this.peerStates.get(peerId)!;//! 非空断言(Non-Null Assertion Operator
}
private setupDataHandler(): void {
this.webrtcConnection.onDataReceived = (data: string | ArrayBuffer, peerId: string) => {
this.handleReceivedData(data, peerId);
};
}
public setProgressCallback(
callback: (fileId: string, progress: number, speed: number) => void,
peerId: string
): void {
const peerState = this.getPeerState(peerId);
peerState.progressCallback = callback;
}
private handleReceivedData(data: string | ArrayBuffer, peerId: string): void {
if (typeof data === 'string') {
try {
const parsedData = JSON.parse(data) as WebRTCMessage;
const peerState = this.getPeerState(peerId);
const handlers: Record<string, () => void> = {
fileRequest: () => this.handleFileRequest(parsedData as FileRequest, peerId),
fileAck: () => {
peerState.isSending = false;
console.log(`Receive file-finish ack from peer ${peerId}`);
}
};
const handler = handlers[parsedData.type];
if (handler) handler();
} catch (error) {
console.error('Error parsing JSON:', error);
}
}
}
//响应 文件请求,发送文件
private async handleFileRequest(
request: FileRequest,
peerId: string
): Promise<void> {
const file = this.pendingFiles.get(request.fileId);
console.log('handleFileRequest',file,peerId);
if (file) {
await this.sendSingleFile(file, peerId);
}
}
// 修改发送字符串的方法为异步方法
public async sendString(content: string, peerId: string): Promise<void> {
const chunks: string[] = [];
for (let i = 0; i < content.length; i += this.chunkSize) {
chunks.push(content.slice(i, i + this.chunkSize));
}
// 先发送元数据
await this.sendWithBackpressure(
JSON.stringify({
type: 'stringMetadata',
length: content.length
}),
peerId
);
// 依次发送每个分片,使用背压控制
for (let i = 0; i < chunks.length; i++) {
const data = JSON.stringify({
type: 'string',
chunk: chunks[i],
index: i,
total: chunks.length
});
await this.sendWithBackpressure(data, peerId);
}
}
public sendFileMeta(files: CustomFile[], peerId?: string): void {
//把属于folder的部分关于文件大小的记录下来,用于计算进度
for (const file of files) {
if (file.folderName){
const fileId = file.folderName;
//folderName:{totalSize:0 , fileIds:[]}
if (!(file.folderName in this.pendingFolerMeta)) {//初始化
this.pendingFolerMeta[fileId] = {totalSize:0 , fileIds:[]};
}
const folderMeta = this.pendingFolerMeta[fileId];
const fileId2 = generateFileId(file);
if (!folderMeta.fileIds.includes(fileId2)){//如果文件没被添加过
folderMeta.fileIds.push(fileId2);
folderMeta.totalSize += file.size;
}
}
}
//循环发送所有文件的meta
const sendToPeers = peerId ? [peerId] : Array.from(this.peerStates.keys());
for (const currentPeerId of sendToPeers) {
for (const file of files) {
const fileId = generateFileId(file);
this.pendingFiles.set(fileId, file);
const fileMeta = this.getFileMeta(file);
console.log('fileMeta',fileMeta);
this.webrtcConnection.sendData(JSON.stringify(fileMeta), currentPeerId);
}
}
}
//发送单个文件
private async sendSingleFile(file: CustomFile, peerId: string): Promise<void> {
const fileId = generateFileId(file);
const peerState = this.getPeerState(peerId);
peerState.isSending = true;
peerState.currentFolderName = file.folderName;
console.log('sendSingleFile',peerId,peerState);
await this.startSendingFile(fileId, peerId);
// 如果当前正在传输文件,则等待传输完成--接收方确认
await this.waitForTransferComplete(peerId);
console.log(`fileId:${fileId} send done or already sent to peer ${peerId}`);
}
private async waitForTransferComplete(peerId: string): Promise<void> {
const peerState = this.getPeerState(peerId);
while (peerState?.isSending) {
await new Promise(resolve => setTimeout(resolve, 50));
}
}
private getFileMeta(file: CustomFile): fileMetadata {
const fileId = generateFileId(file);
const metadata = {type: 'fileMeta',fileId,name: file.name,
size: file.size,fileType: file.type,fullName: file.fullName,folderName:file.folderName
};
return metadata;
}
//同步 文件夹 进度--包含回调
private syncFolderProgress(fileId: string, peerId: string): void {
const folderMeta = this.pendingFolerMeta[fileId];//fileId:{totalSize:0 , fileIds:[]}
const peerState = this.getPeerState(peerId);
if (!peerState) return;
this.speedCalculator.updateSendSpeed(peerId, peerState.totalBytesSent[fileId]);// 使用累计接收量
const speed = this.speedCalculator.getSendSpeed(peerId);
const progress = peerState.totalBytesSent[fileId] / folderMeta.totalSize;
peerState.progressCallback?.(fileId, progress, speed);
}
//更新传输进度,并进行回调
private async updateProgress(byteLength: number, fileId: string, fileSize: number, peerId: string): Promise<void> {
const peerState = this.getPeerState(peerId);
if (!peerState) return;
if (peerState.currentFolderName) {//文件夹状态
this.syncFolderProgress(fileId, peerId);
} else {// 单文件状态
const progress = peerState.totalBytesSent[fileId] / fileSize;
this.speedCalculator.updateSendSpeed(peerId, peerState.totalBytesSent[fileId]);// 使用累计接收量
const speed = this.speedCalculator.getSendSpeed(peerId);
peerState.progressCallback?.(fileId, progress, speed);
}
}
private async sendWithBackpressure(data: string | ArrayBuffer, peerId: string) : Promise<boolean>{
const dataChannel = this.webrtcConnection.dataChannels.get(peerId);
if (!dataChannel) return false;
const threshold = dataChannel.bufferedAmountLowThreshold;
if (dataChannel.bufferedAmount > threshold) {
await new Promise<void>(resolve => {
const onBufferedAmountLow = () => {
dataChannel.removeEventListener('bufferedamountlow', onBufferedAmountLow);
resolve();
};
dataChannel.addEventListener('bufferedamountlow', onBufferedAmountLow);
});
}
return this.webrtcConnection.sendData(data, peerId);
}
//开始发送文件内容
private async startSendingFile(fileId: string, peerId: string): Promise<void> {
const file = this.pendingFiles.get(fileId);
if (!file) return;
const peerState = this.getPeerState(peerId);
const folderId = peerState.currentFolderName?? '';//fileId
if(peerState.currentFolderName){//当前属于文件夹
const index = this.pendingFolerMeta[folderId].fileIds.indexOf(fileId);
if (index === 0){//发送第一个时清零
peerState.totalBytesSent[folderId] = 0;//记录文件夹 总发送字节数
}
}
peerState.totalBytesSent[fileId] = 0;//记录 当前文件 总发送字节数
peerState.readOffset = 0;
peerState.isReading = false;
const fileReader = new FileReader();
const readNextChunk = async (): Promise<void> => {
if (peerState.isReading) return;
peerState.isReading = true;
while (peerState.bufferQueue.length < this.maxBufferSize && peerState.readOffset < file.size) {
const slice = file.slice(peerState.readOffset, peerState.readOffset + this.chunkSize);
try {
const chunk = await this.readChunkAsArrayBuffer(fileReader, slice);
peerState.bufferQueue.push(chunk);
peerState.readOffset += chunk.byteLength;
} catch (error) {
console.error("Error reading file chunk:", error);
break;
}
}
peerState.isReading = false;
};
const sendNextChunk = async (): Promise<void> => {
if (peerState.bufferQueue.length > 0) {
const chunk = peerState.bufferQueue.shift()!;
await this.sendWithBackpressure(chunk, peerId);
if(peerState.currentFolderName){//当前属于文件夹
peerState.totalBytesSent[folderId] += chunk.byteLength;
await this.updateProgress(chunk.byteLength, folderId, file.size, peerId);//更新文件(夹)的进度
}else{
await this.updateProgress(chunk.byteLength, fileId, file.size, peerId);//更新文件的进度
}
peerState.totalBytesSent[fileId] += chunk.byteLength;
if (peerState.totalBytesSent[fileId] < file.size) {//没发送完,继续发送
await readNextChunk();
sendNextChunk();
} else {
const speed = this.speedCalculator.getSendSpeed(peerId);
if(!peerState.currentFolderName)
peerState.progressCallback?.(fileId, 1, speed);//传输单文件时回传
this.finalizeSendFile(fileId, peerId);//发送完,再发送 fileEnd 信号
}
} else if (peerState.totalBytesSent[fileId] < file.size) {//缓冲队列为空,继续读取和发送
await readNextChunk();
sendNextChunk();
}
};
await readNextChunk();//开始读取和发送
sendNextChunk();
}
private readChunkAsArrayBuffer(fileReader: FileReader, blob: Blob): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
fileReader.onload = (e) => {
// 确保 e.target.result 是 ArrayBuffer
if (e.target?.result instanceof ArrayBuffer) {
resolve(e.target.result);
} else {
reject(new Error("Failed to read blob as ArrayBuffer"));
}
};
fileReader.onerror = (error) => reject(error);
fileReader.readAsArrayBuffer(blob);
});
}
//发送 fileEnd 信号
private finalizeSendFile(fileId: string, peerId: string): void {
const endMessage = JSON.stringify({
type: 'fileEnd',
fileId: fileId
});
this.webrtcConnection.sendData(endMessage, peerId);
}
}
export default FileSender;
+120
View File
@@ -0,0 +1,120 @@
import { visit } from 'unist-util-visit';
import remarkGfm from 'remark-gfm';
import type { Root, Element, Text as HastText, Properties } from 'hast';
import type { Plugin } from 'unified';
import type { Root as MdastRoot, Code, Text } from 'mdast';
import type { BuildVisitor } from 'unist-util-visit';
// MDX AST 节点类型定义
interface MdxJsxFlowElement {
type: 'mdxJsxFlowElement';
name: string;
children: Text[];
}
// 扩展的属性类型
interface ExtendedProperties extends Properties {
className?: string;
id?: string;
}
// 扩展的元素类型
interface ExtendedElement extends Omit<Element, 'properties'> {
properties: ExtendedProperties;
}
// 生成合法的 ID,保留中文字符
const generateValidId = (text: string): string => {
return encodeURIComponent(text
.trim() // 移除首尾空格
.replace(/\s+/g, '-') // 将空格替换为连字符
.replace(/\-\-+/g, '-') // 将多个连字符替换为单个
.replace(/^-+/, '') // 移除开头的连字符
.replace(/-+$/, '') // 移除结尾的连字符
);
};
// 获取唯一 ID
const getUniqueId = (baseId: string, usedIds: Set<string>): string => {
let uniqueId = baseId;
let counter = 1;
while (usedIds.has(uniqueId)) {
uniqueId = `${baseId}-${counter}`;
counter++;
}
return uniqueId;
};
export const mdxOptions = {
mdxOptions: {
remarkPlugins: [
remarkGfm,
// mermaid 代码块处理插件
(() => {
return (tree: MdastRoot) => {
visit(tree, 'code', (node: Code) => {
if (node.lang === 'mermaid') {
const mermaidNode = node as unknown as MdxJsxFlowElement;
mermaidNode.type = 'mdxJsxFlowElement';
mermaidNode.name = 'mermaid';
mermaidNode.children = [{ type: 'text', value: node.value } as Text];
}
});
return tree;
};
}) as Plugin<[], MdastRoot>,
],
rehypePlugins: [
// 处理图片和表格的插件
(() => {
return (tree: Root) => {
visit(tree, 'element', ((node: Element, index: number | null, parent: Element | Root | null) => {
if (node.tagName === 'img') {
if (parent && 'tagName' in parent) {
(parent as ExtendedElement).tagName = 'div';
(parent as ExtendedElement).properties = {
...((parent as ExtendedElement).properties || {}),
className: 'image-container'
};
}
}
if (node.tagName === 'table') {
(node as ExtendedElement).properties = {
...((node as ExtendedElement).properties || {}),
className: 'min-w-full divide-y divide-gray-300'
};
}
}) as BuildVisitor<Root, 'element'>);
return tree;
};
}) as Plugin<[], Root>,
// 处理标题 ID 的插件
(() => {
return (tree: Root) => {
const usedIds = new Set<string>();//记录使用的ID,避免重复
visit(tree, 'element', ((node: Element, index: number | null, parent: Element | Root | null) => {
if (['h1', 'h2', 'h3'].includes(node.tagName)) {
let titleText = '';
visit(node, 'text', ((textNode: HastText) => {
titleText += textNode.value;
}) as BuildVisitor<Element, 'text'>);
if (titleText) {
let id = generateValidId(titleText);
let uniqueId = getUniqueId(id, usedIds);// 处理重复 ID,加数字后缀
usedIds.add(uniqueId);
(node as ExtendedElement).properties = {
...((node as ExtendedElement).properties || {}),
id: uniqueId
};
}
}
}) as BuildVisitor<Root, 'element'>);
return tree;
};
}) as Plugin<[], Root>,
],
},
};
+88
View File
@@ -0,0 +1,88 @@
import {CustomFile } from '@/lib/types/file';
//对文件大小自适应单位并格式化输出
export const formatFileSize = (sizeInBytes: number) => {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = sizeInBytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
};
//用来计算传输速度,支持多个 peerId
export class SpeedCalculator {
private speeds: Map<string, number>;//peerId,speed
private windowSize: number = 2; // 5秒的滑动窗口
private transferHistory: Map<string, Array<{time: number, totalBytes: number}>>;//peerId={time,totalBytes}
private maxSpeed: number = 1024 * 1024; // 最大速度限制(KB/s
private lastUpdateTimes: Map<string, number>; // 记录每个peerId最后更新时间
private updateInterval: number = 100; // 最小更新间隔(ms
constructor() {
this.speeds = new Map();
this.transferHistory = new Map();
this.lastUpdateTimes = new Map();
}
updateSendSpeed(peerId: string, totalBytesSent: number) {
const now = Date.now();
// 检查是否达到更新间隔
const lastUpdate = this.lastUpdateTimes.get(peerId) || 0;
if (now - lastUpdate < this.updateInterval) {
return; // 如果间隔太短,直接返回
}
// 初始化或获取传输历史
if (!this.transferHistory.has(peerId)) {
this.transferHistory.set(peerId, []);
}
const history = this.transferHistory.get(peerId)!;
// 添加新的累计传输记录
history.push({ time: now, totalBytes: totalBytesSent });
// 移除窗口外的旧数据
const windowStart = now - this.windowSize * 1000;
while (history.length > 0 && history[0].time < windowStart) {
history.shift();
}
// 计算窗口内的总传输量和时间差
if (history.length > 1) {
// 使用窗口内第一个和最后一个点来计算速度
const firstRecord = history[0];
const lastRecord = history[history.length - 1];
const bytesDiff = lastRecord.totalBytes - firstRecord.totalBytes;
const timeSpan = (lastRecord.time - firstRecord.time) / 1000; // 转换为秒
// 计算速度(KB/s)并应用限制
let speed = timeSpan > 0 ? bytesDiff / 1024 / timeSpan : 0;
speed = Math.min(speed, this.maxSpeed);
// 减小平滑因子,使速度更快反应变化
const oldSpeed = this.speeds.get(peerId) || 0;
const smoothingFactor = 0.3; // 减小平滑因子
const smoothedSpeed = oldSpeed * (1 - smoothingFactor) + speed * smoothingFactor;
this.speeds.set(peerId, smoothedSpeed);
}
// 更新最后更新时间
this.lastUpdateTimes.set(peerId, now);
}
getSendSpeed(peerId: string): number {
return this.speeds.get(peerId) || 0;
}
}
export const generateFileId = (file:CustomFile):string => {
return `${file.fullName}-${file.size}-${file.type}-${file.lastModified}`;
}
+82
View File
@@ -0,0 +1,82 @@
export interface Progress {//file Progress
progress: number;
speed: number;
}
export interface CustomFile extends File {// CustomFile 扩展了 File 接口,任然是File对象
fullName: string;//文件路径,格式:root+...+filename,比如test/test.txt,test/sub/test2.txtroot是拖拽的文件夹名
folderName: string;//该文件所属文件夹,如果没有则为空,eg:root or test or ''
}
export interface FileMeta {//单文件和文件夹共用这个接口
name: string;//fileName or folderName
size: number;//对于文件夹是total size
fullName: string;//文件路径,格式:root+...+filename
folderName: string;
fileType: string;//与通信中的type区分开
fileId: string;//文件夹暂时 等于 folderName
fileCount?: number;//文件夹才有
fileNamesDis?: string;//文件夹下所有文件名的展示
}
export interface fileMetadata extends FileMeta {
type: string;
}
export interface FileRequest {
type: 'fileRequest';
fileId: string;
}
export interface FileAck {
type: 'fileAck';
fileId: string;
}
export interface StringMetadata {
type: 'stringMetadata';
length: number;
}
export interface StringChunk {
type: 'string';
chunk: string;
index: number;
total: number;
}
export interface FileEnd {
type: 'fileEnd';
fileId: string;
}
export type WebRTCMessage = fileMetadata | FileRequest | FileAck | StringMetadata | StringChunk | FileEnd;
export interface FolderMeta {
totalSize: number;
fileIds: string[];
}
export interface FolderProgress extends FolderMeta {
receivedSize: number;
}
export interface PeerState {
isSending: boolean;
bufferQueue: ArrayBuffer[];
readOffset: number;
isReading: boolean;
totalBytesSent: Record<string, number>;
progressCallback: ((id: string, progress: number, speed: number) => void) | null;
currentFolderName?: string;
}
export interface CurrentString {
length: number;
chunks: string[];
receivedChunks: number;
}
export interface FileHandlers {
string: (data: any, peerId: string) => void;
stringMetadata: (data: any, peerId: string) => void;
fileMeta: (data: any, peerId: string) => Promise<void>;
fileEnd: (data: any) => Promise<void>;
}
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+52
View File
@@ -0,0 +1,52 @@
//当 Android 设备切换到其他应用时,屏幕会保持唤醒状态,WebRTC 连接也就不会断开了。需要注意的是,这会增加设备的电量消耗,所以在连接断开时及时释放 wake lock 很重要。
export class WakeLockManager {
private wakeLock: WakeLockSentinel | null = null;
private isSupported: boolean = false;
constructor() {
// 检查浏览器是否支持 Wake Lock API
this.isSupported = 'wakeLock' in navigator;
}
async requestWakeLock(): Promise<void> {
if (!this.isSupported) {
console.warn('Wake Lock API is not supported in this browser');
return;
}
if (document.visibilityState !== 'visible') {//只在页面可见时请求
console.warn('Wake Lock API should request in visible state');
return;
}
try {
// 请求screen wake lock
this.wakeLock = await navigator.wakeLock.request('screen');
// 监听visibility change事件,在页面重新可见时重新请求wake lock
document.addEventListener('visibilitychange', this.handleVisibilityChange);
console.log('Wake Lock is active');
} catch (err) {
console.error('Error requesting wake lock:', err);
}
}
private handleVisibilityChange = async () => {
if (document.visibilityState === 'visible' && this.wakeLock === null) {
// 页面重新可见时,重新请求wake lock
await this.requestWakeLock();
}
};
async releaseWakeLock(): Promise<void> {
if (!this.wakeLock) return;
try {
await this.wakeLock.release();
this.wakeLock = null;
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
console.log('Wake Lock is released');
} catch (err) {
console.error('Error releasing wake lock:', err);
}
}
}
+100
View File
@@ -0,0 +1,100 @@
// 发起方 流程: 加入房间; 收到 'ready' 事件(新的接收方进入后socker server就会触发这个事件) -> createPeerConnection + createDataChannel -> createAndSendOffer
import BaseWebRTC from './webrtc_base';
import { postLogInDebug } from '@/app/config/api';
const developmentEnv = process.env.NEXT_PUBLIC_development!;//开发环境
export default class WebRTC_Initiator extends BaseWebRTC {
constructor(signalingServer: string) {
super(signalingServer);
this.setupInitiatorSocketListeners();
}
private setupInitiatorSocketListeners() {
this.socket.on('ready', ({ peerId }) => {//新进入房间的 接收方 peerId
this.handleReady({ peerId });
});
// 添加接收方响应的监听
this.socket.on('recipient-ready', ({ peerId }) => {
if (developmentEnv === 'true')postLogInDebug(`[Initiator] Received recipient-ready from: ${peerId}`);
this.handleReady({ peerId });
});
// 添加answer处理监听器
this.socket.on('answer', ({ answer, peerId, from }) => {
this.handleAnswer({ answer, peerId, from });
});
}
//发送方收到接收方加入时创建连接
private async handleReady({ peerId }: { peerId: string }): Promise<void> {//接收方 peerId
// console.log(`Received ready signal from peer ${peerId}`);
if (developmentEnv === 'true')postLogInDebug(`Received ready signal from peer ${peerId}`);
await this.createPeerConnection(peerId);
await this.createDataChannel(peerId);
await this.createAndSendOffer(peerId);
}
private async handleAnswer({ answer, peerId, from }: { answer: RTCSessionDescriptionInit; peerId: string; from: string }): Promise<void> {
// console.log(`Handling answer from peer ${from}`);
if (developmentEnv === 'true')postLogInDebug(`Handling answer from peer ${from}`);
const peerConnection = this.peerConnections.get(from);
if (!peerConnection) {
console.error(`No peer connection found for peer ${from}`);
return;
}
try {
await peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
// console.log(`Remote description set for peer ${from}`);
// 在设置远程描述后处理队列中的ICE候选
await this.addQueuedIceCandidates(from);
} catch (error) {
console.error('Error handling answer:', error);
}
}
protected async createDataChannel(peerId: string): Promise<void> {
const peerConnection = this.peerConnections.get(peerId);
if (!peerConnection) {
console.error(`No peer connection found for peer ${peerId}`);
return;
}
try {
const dataChannel = peerConnection.createDataChannel('dataChannel', {
ordered: true,
// reliable: true
});
// console.log(`Created data channel for peer ${peerId}`);
dataChannel.bufferedAmountLowThreshold = 262144; //256 KB -- 可以根据需要调整
this.setupDataChannel(dataChannel, peerId);
this.dataChannels.set(peerId, dataChannel);
} catch (error) {
console.error(`Error creating data channel for peer ${peerId}:`, error);
}
}
// 如果是发起方,创建并发送offer给信令服务器,以便与接收方协商建立连接。
private async createAndSendOffer(peerId: string): Promise<void> {
// console.log('createAndSendOffer',peerId);
if (developmentEnv === 'true')postLogInDebug(`createAndSendOffer for peerId: ${peerId}`);
const peerConnection = this.peerConnections.get(peerId);
if (!peerConnection) {
console.error(`No peer connection found for peer ${peerId}`);
return;
}
try {
const offer = await peerConnection.createOffer();
await peerConnection.setLocalDescription(offer);
// console.log('createAndSendOffer',peerId,this.roomId,offer);
this.socket.emit('offer', {
roomId: this.roomId,
peerId: peerId,
offer: offer,
from: this.socket.id
});
} catch (error) {
console.error('Error creating and sending offer:', error);
}
}
}
+98
View File
@@ -0,0 +1,98 @@
// 接收方 流程: 加入房间; 收到 'offer' 事件 -> createPeerConnection + createDataChannel -> 发送 answer
import BaseWebRTC from './webrtc_base';
import { postLogInDebug } from '@/app/config/api';
const developmentEnv = process.env.NEXT_PUBLIC_development!;//开发环境
interface AnswerPayload {
answer: RTCSessionDescriptionInit;
peerId: string;
}
export default class WebRTC_Recipient extends BaseWebRTC {
constructor(signalingServer: string) {
super(signalingServer);
this.setupRecipientSocketListeners();
}
private setupRecipientSocketListeners(): void {
this.socket.on('offer', ({ peerId, offer, from }) => {
this.handleOffer({ peerId,offer, from });
});
this.socket.on('answer', ({ answer, peerId }) => {
this.handleAnswer({ answer, peerId });
});
// 添加发起方重新上线的监听
this.socket.on('initiator-online', ({ roomId }) => {
console.log(`[Recipient] Received initiator-online for room: ${roomId}`,this.roomId);
// 发送准备就绪的响应
console.log(`[Recipient] Sending recipient-ready, my peerId: ${this.socket.id}`,this.peerId);
// 发送准备就绪的响应
this.socket.emit('recipient-ready', {
roomId: this.roomId,
peerId: this.socket.id
});
});
}
// 接收方 收到 offer 时创建连接
private async handleOffer({ peerId, offer, from }: { offer: RTCSessionDescriptionInit; peerId: string; from: string }): Promise<void> {
console.log(`Handling offer from peer ${from}`);
try {
// 1. 清理已存在的连接
await this.cleanupExistingConnection(from);
// 2. 创建新的连接
const peerConnection = await this.createPeerConnection(from);
// 再创建数据通道
await this.createDataChannel(from);
// 4. 设置远程描述
// console.log(`Setting remote description for peer ${from}`);
await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
// 创建并设置本地描述(answer)
// console.log(`Creating answer for peer ${from}`);
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
// 发送 answer
console.log(`Sending answer to peer ${from}`);
this.socket.emit('answer', {
answer,
peerId: from,
from: this.socket.id
});
// 最后处理已缓存的 ICE candidates
await this.addQueuedIceCandidates(from);
} catch (error) {
console.error('Error handling offer:', error);
// 清理失败的连接
await this.cleanupExistingConnection(from);
}
}
private async handleAnswer({ answer, peerId }: AnswerPayload): Promise<void> {
const peerConnection = this.peerConnections.get(peerId);
if (!peerConnection) return;
try {
await peerConnection.setRemoteDescription(new RTCSessionDescription(answer));
} catch (error) {
console.error('Error handling answer:', error);
}
}
protected async createDataChannel(peerId: string): Promise<void> {
const peerConnection = this.peerConnections.get(peerId);
if (!peerConnection) {
console.error(`No peer connection found for peer ${peerId}`);
return;
}
peerConnection.ondatachannel = (event) => {
// console.log(`Received data channel from peer ${peerId}`);
this.setupDataChannel(event.channel, peerId);
this.dataChannels.set(peerId, event.channel);
};
}
}
+422
View File
@@ -0,0 +1,422 @@
// BaseWebRTC.js
import io from 'socket.io-client';
import { Socket } from 'socket.io-client';
import { getIceServers, getSocketOptions } from '@/app/config/environment';
import { WakeLockManager } from './wakeLockManager';
import { postLogInDebug } from '@/app/config/api';
const developmentEnv = process.env.NEXT_PUBLIC_development!;//开发环境
interface JoinRoomResponse {
success: boolean;
message: string;
roomId: string;
error?: string;
}
interface CallbackTypes {
onDataChannelOpen?: (peerId: string) => void;
onDataReceived?: (data: string | ArrayBuffer, peerId: string) => void;
onConnectionEstablished?: (peerId: string) => void;
onConnectionStateChange?: (state: RTCPeerConnectionState, peerId: string) => void;
}
export default class BaseWebRTC {
//类型申明
protected iceServers: RTCIceServer[];
protected socket: Socket;
public peerConnections: Map<string, RTCPeerConnection>;
public dataChannels: Map<string, RTCDataChannel>;
public onDataChannelOpen: CallbackTypes['onDataChannelOpen'] | null;
public onDataReceived: CallbackTypes['onDataReceived'] | null;
protected onConnectionEstablished: CallbackTypes['onConnectionEstablished'] | null;
public onConnectionStateChange: CallbackTypes['onConnectionStateChange'] | null;
protected iceCandidatesQueue: Map<string, RTCIceCandidateInit[]>;
protected roomId: string | null;
protected peerId: string | undefined | null;
public isInRoom: boolean;
protected isInitiator: boolean;//标记发起方
//重连相关
protected isSocketDisconnected: boolean;//跟踪 socket 连接状态
protected isPeerDisconnected: boolean;//跟踪 P2P 连接状态
protected reconnectionInProgress: boolean;//防止重复重连
protected wakeLockManager: WakeLockManager;
constructor(signalingServer: string) {// signalingServer: 信令服务器的URL,用于初始化Socket.IO连接。
this.iceServers = getIceServers();
this.socket = io(signalingServer, getSocketOptions());
this.peerConnections = new Map();// Map<targetPeerId, RTCPeerConnection>
this.dataChannels = new Map();// Map<targetPeerId, RTCDataChannel>
// Callbacks
this.onDataChannelOpen = null;//当数据通道建立时的回调
this.onDataReceived = null;//接收数据--响应
this.onConnectionEstablished = null;//当WebRTC连接建立时触发。
this.onConnectionStateChange = null;//监控和响应连接状态的变化
this.iceCandidatesQueue = new Map();// 为每个peer存储ice候选项
this.roomId = null;
this.peerId = null;//自己的 ID
this.isInRoom = false;//是否已经加入过房间
this.setupCommonSocketListeners();
this.isInitiator = false;
this.isSocketDisconnected = false;
this.isPeerDisconnected = false;
this.reconnectionInProgress = false;
this.wakeLockManager = new WakeLockManager();
}
// 设置信令服务器的事件监听器,用于处理各种信令消息(连接、ICE候选者、offer、answer等)。
setupCommonSocketListeners() {
this.socket.on('connect', () => {
this.peerId = this.socket.id;//保存自己的 ID
console.log('Connected to signaling server, peerId:', this.peerId);
});
this.socket.on('error', (error) => {
console.error('Socket error:', error);
});
this.socket.on('ice-candidate', ({ candidate, peerId, from }) => {//接受方 peerId
// console.log(`Received ICE candidate from ${from} for ${peerId}`);
this.handleIceCandidate({candidate, peerId, from});
});
// 添加 socket 断开连接的监听
this.socket.on('disconnect', () => {
this.isInRoom = false;
this.isSocketDisconnected = true;
if(developmentEnv === 'true')postLogInDebug(`${this.peerId} disconnect on socket,isInitiator:${this.isInitiator},isInRoom:${this.isInRoom}`);
// 尝试重连.//移动端切换到后台之后,P2P连接和socket连接都会断开.在切回来时,才会执行断开的代码,直接在这里重连;发送重连开始新号
this.attemptReconnection();
});
}
protected async attemptReconnection(): Promise<void> {
if (this.reconnectionInProgress) return;
if (this.isSocketDisconnected && this.isPeerDisconnected && this.roomId) {//等socket和P2P连接都断开之后再开始重连
this.reconnectionInProgress = true;
if(developmentEnv === 'true') {
postLogInDebug(`Starting reconnection, socket and peer both disconnected. isInitiator:${this.isInitiator}`);
}
try {
const sendInitiatorOnline = this.isInitiator;
await this.joinRoom(this.roomId, this.isInitiator, sendInitiatorOnline);
// 重置状态
this.isSocketDisconnected = false;
this.isPeerDisconnected = false;
} catch (error) {
console.error('Reconnection failed:', error);
} finally {
this.reconnectionInProgress = false;
}
}
}
protected async handleIceCandidate(
{ candidate, peerId, from }: { candidate: RTCIceCandidateInit; peerId: string; from: string }
): Promise<void> {
// console.log(`Handling ICE candidate from ${from} for ${peerId}`);
const peerConnection = this.peerConnections.get(from);
// console.log(`this.peerConnections`,this.peerConnections);
if (!peerConnection) {
// console.warn(`No peer connection found for ${from}, queuing candidate`);
if (!this.iceCandidatesQueue.has(from)) {
this.iceCandidatesQueue.set(from, []);
}
this.iceCandidatesQueue.get(from)?.push(candidate);
return;
}
try {
// 只有在远程描述设置完成且连接未关闭的情况下才添加ICE候选项
if (peerConnection.remoteDescription &&
peerConnection.signalingState !== 'closed' &&
peerConnection.connectionState !== 'closed') {
await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
// console.log(`Successfully added ICE candidate for ${from}`);
} else {
// console.warn(`Remote description not set or connection closed for ${from}, queuing candidate`);
// console.warn(`remoteDescription`,peerConnection.remoteDescription,'peerConnection.signalingState',peerConnection.signalingState);
if (!this.iceCandidatesQueue.has(from)) {
this.iceCandidatesQueue.set(from, []);
}
this.iceCandidatesQueue.get(from)?.push(candidate);
}
} catch (e) {
console.error(`Error adding ICE candidate for ${from}:`, e);
// 如果添加失败,也将其加入队列
if (!this.iceCandidatesQueue.has(from)) {
this.iceCandidatesQueue.set(from, []);
}
this.iceCandidatesQueue.get(from)?.push(candidate);
}
}
protected async addQueuedIceCandidates(peerId: string): Promise<void> {
const candidates = this.iceCandidatesQueue.get(peerId);
const peerConnection = this.peerConnections.get(peerId);
// console.log(`Attempting to add ${candidates?.length || 0} queued candidates for ${peerId}`);
// console.log(`Connection state: ${peerConnection?.connectionState}`);
// console.log(`Signaling state: ${peerConnection?.signalingState}`);
if (!peerConnection || !candidates?.length) {
return;
}
if (peerConnection.remoteDescription &&
peerConnection.signalingState !== 'closed' &&
peerConnection.connectionState !== 'closed') {
// console.log(`Adding ${candidates.length} queued candidates for ${peerId}`);
for (const candidate of candidates) {
try {
await peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
// console.log(`Successfully added queued candidate for ${peerId}`);
}
catch (e) {
console.error('Error adding queued ice candidates', e);
}
}
// 只有在成功添加所有候选项后才清空队列
this.iceCandidatesQueue.delete(peerId);
} else {
console.warn(`Connection not ready for ${peerId}, keeping candidates queued`);
// console.warn(`remoteDescription`,peerConnection?.remoteDescription);
}
}
protected async createPeerConnection(peerId: string): Promise<RTCPeerConnection> {
// console.log('Creating peer connection for:', peerId);
const peerConnection = this.peerConnections.get(peerId);
if (peerConnection) {
// console.log('Reusing existing peer connection for:', peerId);
return Promise.resolve(peerConnection);
}
// WebRTC默认提供了强大的加密功能,上线后要改为https协议
const newPeerConnection = new RTCPeerConnection({ iceServers: this.iceServers });
// // 增加更详细的连接状态监控
// newPeerConnection.oniceconnectionstatechange = () => {
// console.log(`ICE Connection State (${peerId}):`, newPeerConnection.iceConnectionState);
// };
// newPeerConnection.onsignalingstatechange = () => {
// console.log(`Signaling State (${peerId}):`, newPeerConnection.signalingState);
// };
newPeerConnection.onconnectionstatechange = () => {
// const state = newPeerConnection.connectionState;
// console.log(`Connection State (${peerId}):`, state);
this.handleConnectionStateChange(peerId, newPeerConnection);
};
// 改进ICE候选项处理
newPeerConnection.onicecandidate = (event) => {
if (event.candidate) {
// console.log(`Sending ICE candidate to ${peerId}:`, event.candidate);
this.socket.emit('ice-candidate', {
candidate: event.candidate,
peerId: peerId,
from: this.socket.id // 添加发送方ID
});
}
};
// // 添加ICE收集状态监控
// newPeerConnection.onicegatheringstatechange = () => {
// console.log(`ICE Gathering State (${peerId}):`, newPeerConnection.iceGatheringState);
// };
this.peerConnections.set(peerId, newPeerConnection);
// console.log('New peer connection created for:', peerId);
return Promise.resolve(newPeerConnection);
}
protected handleConnectionStateChange(peerId: string, peerConnection: RTCPeerConnection): void {
const state = peerConnection.connectionState;
// console.log('Connection state change:', state);
const stateHandlers = {
connected: async () => {
this.isPeerDisconnected = false;
const dataChannel = this.dataChannels.get(peerId);
if (!dataChannel) {
this.createDataChannel(peerId);
}
this.onConnectionEstablished?.(peerId);
// 在连接建立时请求 wake lock
await this.wakeLockManager.requestWakeLock();
},
disconnected: async () => {
await this.cleanupExistingConnection(peerId);
this.isPeerDisconnected = true;
if (developmentEnv === 'true')postLogInDebug(`p2p disconnected, isInitiator:${this.isInitiator}`);
// 尝试重连
this.attemptReconnection();
await this.wakeLockManager.releaseWakeLock();
},
failed: async () => {
this.cleanupExistingConnection(peerId);
this.isPeerDisconnected = true;
await this.wakeLockManager.releaseWakeLock();
},
closed: async () => {
this.cleanupExistingConnection(peerId);
this.isPeerDisconnected = true;
await this.wakeLockManager.releaseWakeLock();
},
// 以下必须添加,防止报错
connecting: () => {console.log("Peer is connecting");},
new: () => {console.log("New connection state");}
};
stateHandlers[state]?.();
this.onConnectionStateChange?.(state, peerId);
}
protected setupDataChannel(dataChannel: RTCDataChannel, peerId: string): void {
dataChannel.onopen = () => {
// console.log(`Data channel opened for peer ${peerId}`);
setTimeout(() => {
this.onDataChannelOpen?.(peerId);
}, 50);
};
dataChannel.onmessage = (event) => {
this.onDataReceived?.(event.data, peerId);
};
}
// 加入房间,sendInitiatorOnline表示加入房间之后,是否要发送“发起方重新在线”消息
public async joinRoom(roomId: string, isInitiator:boolean, sendInitiatorOnline:boolean = false): Promise<void> {
// 如果已经在房间里,直接返回
if (this.isInRoom) {
return;
}
this.isInitiator = isInitiator;
return new Promise<void>((resolve, reject) => {
// 设置超时时间(5秒)
const timeout = setTimeout(() => {
this.socket.off('joinResponse');
reject(new Error('Join room timeout'));
this.isInRoom = false;
this.roomId = null;
}, 5000);
// 监听加入房间响应--一次
this.socket.once('joinResponse', (response: JoinRoomResponse) => {
clearTimeout(timeout); // 清除超时定时器
if (response.success) {
this.roomId = roomId;
this.isInRoom = true;
if(sendInitiatorOnline){
this.socket.emit('initiator-online', {
roomId: this.roomId
});
}
if (developmentEnv === 'true')postLogInDebug(`peerId:${this.socket.id} Successfully joined room: ${response.roomId},isInitiator:${this.isInitiator},isInRoom:${this.isInRoom}`);
resolve();
} else {
this.isInRoom = false;
this.roomId = null;
if (developmentEnv === 'true')postLogInDebug(`Failed to join room,message:${response.message}`);
console.error('Failed to join room:', response.message);
reject(new Error(response.message));
}
});
// 发送加入房间请求
try {
this.socket.emit('join', {roomId});
} catch (error) {
clearTimeout(timeout);
this.isInRoom = false;
this.roomId = null;
reject(error);
}
});
}
//如果指定peerId,则发送给特定接收方,否则广播
public sendData(data: any, peerId?: string | null): boolean {
if (peerId) {
return this.sendToPeer(data, peerId);
} else {
let success = true;
for (const peerId of Object.keys(this.dataChannels)) {
if (!this.sendToPeer(data, peerId)) {
success = false;
}
}
return success;
}
}
//发送给特定对象
protected sendToPeer(data: any, peerId: string): boolean {
const dataChannel = this.dataChannels.get(peerId);
if (dataChannel?.readyState === 'open') {
dataChannel.send(data);
return true;
}
console.warn(`Data channel not ready for peer ${peerId}. Retrying...`);
this.retryDataSend(data, peerId);
return false;
}
protected retryDataSend(data: any, peerId: string): void {
const maxRetries = 5;
let retryCount = 0;
const attemptSend = () => {
const dataChannel = this.dataChannels.get(peerId);
if (dataChannel?.readyState === 'open') {
dataChannel.send(data);
} else if (retryCount < maxRetries) {
retryCount++;
console.log(`Retrying to send data to peer ${peerId}. Attempt ${retryCount} of ${maxRetries}`);
setTimeout(attemptSend, 1000);
} else {
console.error(`Failed to send data to peer ${peerId} after maximum retries`);
}
};
setTimeout(attemptSend, 100);
}
protected async closeDataChannel(peerId: string): Promise<void> {
const dataChannel = this.dataChannels.get(peerId);
if (dataChannel) {
dataChannel.close();
this.dataChannels.delete(peerId);
}
}
protected async cleanupExistingConnection(peerId: string): Promise<void> {
this.closeDataChannel(peerId);
const peerConnection = this.peerConnections.get(peerId);
if (peerConnection) {
peerConnection.close();
this.peerConnections.delete(peerId);
}
this.iceCandidatesQueue.delete(peerId);
}
public async cleanUpBeforeExit() {
for (const peerId of Object.keys(this.peerConnections)) {
this.cleanupExistingConnection(peerId);
}
if (this.socket) {
this.socket.disconnect();
}
this.isInRoom = false;
}
// 抽象方法声明
protected createDataChannel(peerId: string) {
throw new Error('createDataChannel must be implemented by subclass');
}
}
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { i18n } from '@/constants/i18n-config'
import { match as matchLocale } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
function getLocale(request: NextRequest): string {
// 1. 获取请求中的 Accept-Language
const negotiatorHeaders: Record<string, string> = {};
request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));
const locales = i18n.locales;
// 2. 使用 negotiator 获取所有支持的语言
const languages = new Negotiator({ headers: negotiatorHeaders }).languages();
try {
// 3. 匹配最佳语言
const locale = matchLocale(languages, locales, i18n.defaultLocale)
return locale
} catch (error) {
return i18n.defaultLocale
}
}
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// 获取所有的查询参数
const searchParams = request.nextUrl.searchParams;
// 检查请求路径是否已包含语言前缀
const pathnameIsMissingLocale = i18n.locales.every(
(locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
);
// 如果路径中没有语言前缀,则重定向到带有语言前缀的路径
if (pathnameIsMissingLocale) {
const locale = getLocale(request);
// 创建新的 URL,保留原有的查询参数
const newUrl = new URL(`/${locale}${pathname}`, request.url);
// 将原有的查询参数复制到新 URL
searchParams.forEach((value, key) => {
newUrl.searchParams.set(key, value);
});
return NextResponse.redirect(newUrl);
}
}
export const config = {
// 排除 public 文件、api 路由和 sitemap 相关路由
//排除了常见的静态资源文件扩展名(如 .png, .jpg, .gif 等),确保这些路径不会被中间件捕获
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|\\.png$|\\.jpg$|\\.jpeg$|\\.gif$|\\.svg$).*)',
],
};
+27
View File
@@ -0,0 +1,27 @@
import createMDX from '@next/mdx'
const withMDX = createMDX({
extension: /\.mdx?$/,
options: {
remarkPlugins: [],
rehypePlugins: [],
},
})
/** @type {import('next').NextConfig} */
const nextConfig = {
pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'],
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'img.youtube.com',
pathname: '/vi/**',
},
],
unoptimized: true, // 禁用图片优化
},
}
export default withMDX(nextConfig);
+60
View File
@@ -0,0 +1,60 @@
{
"name": "my-clipboard-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -H 0.0.0.0",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@formatjs/intl-localematcher": "^0.5.10",
"@mdx-js/loader": "^3.1.0",
"@mdx-js/react": "^3.1.0",
"@next/mdx": "^15.1.5",
"@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-checkbox": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.2",
"@radix-ui/react-tooltip": "^1.1.2",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"@types/negotiator": "^0.6.3",
"@types/node": "^20",
"@types/react-dom": "^18",
"@types/unist": "^3.0.3",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"gray-matter": "^4.0.3",
"jszip": "^3.10.1",
"lodash": "^4.17.21",
"lucide-react": "^0.417.0",
"mermaid": "^11.4.1",
"negotiator": "^1.0.0",
"next": "14.2.5",
"next-mdx-remote": "^5.0.0",
"next-themes": "^0.3.0",
"qrcode.react": "^4.0.1",
"react": "^18",
"react-dom": "^18",
"remark-gfm": "^4.0.0",
"sharp": "^0.33.5",
"socket.io-client": "^4.7.5",
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7",
"unified": "^11.0.5",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@types/react": "^18.3.18",
"eslint": "^8",
"eslint-config-next": "14.2.5",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

Some files were not shown because too many files have changed in this diff Show More