reformat all ts code via prettier
This commit is contained in:
@@ -1,81 +1,82 @@
|
||||
// scripts/export-tracking-data.js
|
||||
const Redis = require('ioredis');
|
||||
const fs = require('fs/promises');
|
||||
const path = require('path');
|
||||
const Redis = require("ioredis");
|
||||
const fs = require("fs/promises");
|
||||
const path = require("path");
|
||||
|
||||
// Redis client configuration
|
||||
const redis = new Redis({
|
||||
host: 'localhost',
|
||||
host: "localhost",
|
||||
port: 6379,
|
||||
});
|
||||
|
||||
async function exportTrackingData() {
|
||||
try {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const fileName = `tracking-data-${timestamp}.txt`;
|
||||
const filePath = path.join(__dirname, '../logs', fileName);
|
||||
const filePath = path.join(__dirname, "../logs", fileName);
|
||||
|
||||
// Create output content
|
||||
let output = '=== Tracking Data Export ===\n';
|
||||
let output = "=== Tracking Data Export ===\n";
|
||||
output += `Generated at: ${new Date().toISOString()}\n\n`;
|
||||
|
||||
// 1. Get all sources
|
||||
const sources = await redis.smembers('referrers:sources');
|
||||
output += '=== Referral Sources ===\n';
|
||||
output += sources.join(', ') + '\n\n';
|
||||
const sources = await redis.smembers("referrers:sources");
|
||||
output += "=== Referral Sources ===\n";
|
||||
output += sources.join(", ") + "\n\n";
|
||||
|
||||
// 2. Get total counts
|
||||
const totalCounts = await redis.hgetall('referrers:count');
|
||||
output += '=== Total Counts by Source ===\n';
|
||||
const totalCounts = await redis.hgetall("referrers:count");
|
||||
output += "=== Total Counts by Source ===\n";
|
||||
for (const [source, count] of Object.entries(totalCounts)) {
|
||||
output += `${source}: ${count}\n`;
|
||||
}
|
||||
output += '\n';
|
||||
output += "\n";
|
||||
|
||||
// 3. Get daily statistics and sort by date
|
||||
output += '=== Daily Statistics ===\n';
|
||||
const dailyKeys = await redis.keys('referrers:daily:*');
|
||||
output += "=== Daily Statistics ===\n";
|
||||
const dailyKeys = await redis.keys("referrers:daily:*");
|
||||
|
||||
// Convert keys to an array of objects with dates and sort by date
|
||||
const dailyData = await Promise.all(dailyKeys.map(async (key) => {
|
||||
const date = key.split(':')[2];
|
||||
const dailyStats = await redis.hgetall(key);
|
||||
const dailyData = await Promise.all(
|
||||
dailyKeys.map(async (key) => {
|
||||
const date = key.split(":")[2];
|
||||
const dailyStats = await redis.hgetall(key);
|
||||
return { date: new Date(date), data: dailyStats };
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
||||
dailyData.sort((a, b) => b.date.getTime() - a.date.getTime()); // Sort by date in descending order (most recent to oldest)
|
||||
|
||||
for (const item of dailyData) {
|
||||
const dateString = item.date.toISOString().split('T')[0]; // Format date string
|
||||
output += `\nDate: ${dateString}\n`;
|
||||
for (const [source, count] of Object.entries(item.data)) {
|
||||
const dateString = item.date.toISOString().split("T")[0]; // Format date string
|
||||
output += `\nDate: ${dateString}\n`;
|
||||
for (const [source, count] of Object.entries(item.data)) {
|
||||
output += ` ${source}: ${count}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
output += '\n';
|
||||
output += "\n";
|
||||
|
||||
// 5. Add basic statistics
|
||||
output += '\n=== Summary ===\n';
|
||||
output += "\n=== Summary ===\n";
|
||||
output += `Total Sources: ${sources.length}\n`;
|
||||
|
||||
|
||||
// Ensure the log directory exists
|
||||
await fs.mkdir(path.join(__dirname, '../logs'), { recursive: true });
|
||||
|
||||
await fs.mkdir(path.join(__dirname, "../logs"), { recursive: true });
|
||||
|
||||
// Write to file
|
||||
await fs.writeFile(filePath, output, 'utf8');
|
||||
|
||||
await fs.writeFile(filePath, output, "utf8");
|
||||
|
||||
console.log(`Data exported successfully to: ${filePath}`);
|
||||
console.log('\nFile Preview:');
|
||||
console.log('='.repeat(50));
|
||||
console.log(output.slice(0, 500) + '...');
|
||||
console.log('='.repeat(50));
|
||||
console.log("\nFile Preview:");
|
||||
console.log("=".repeat(50));
|
||||
console.log(output.slice(0, 500) + "...");
|
||||
console.log("=".repeat(50));
|
||||
|
||||
// Close Redis connection
|
||||
await redis.quit();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error exporting data:', error);
|
||||
console.error("Error exporting data:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
import { CorsOptions } from 'cors';
|
||||
import { CONFIG } from './env';
|
||||
import { CorsOptions } from "cors";
|
||||
import { CONFIG } from "./env";
|
||||
// Configure CORS
|
||||
export const corsOptions: CorsOptions = CONFIG.NODE_ENV === 'production'
|
||||
? {
|
||||
origin: CONFIG.CORS_ORIGIN,
|
||||
methods: ['GET', 'POST', 'OPTIONS'],
|
||||
credentials: true,
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
}
|
||||
: {
|
||||
origin: true, // Allow all origins in development environment
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization']
|
||||
};
|
||||
export const corsOptions: CorsOptions =
|
||||
CONFIG.NODE_ENV === "production"
|
||||
? {
|
||||
origin: CONFIG.CORS_ORIGIN,
|
||||
methods: ["GET", "POST", "OPTIONS"],
|
||||
credentials: true,
|
||||
allowedHeaders: ["Content-Type", "Authorization"],
|
||||
}
|
||||
: {
|
||||
origin: true, // Allow all origins in development environment
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization"],
|
||||
};
|
||||
// Configure CORS for Socket.IO
|
||||
export const corsWSOptions = CONFIG.NODE_ENV === 'production'
|
||||
? {
|
||||
origin: CONFIG.CORS_ORIGIN, // Allowed origin, replace with your Next.js application's URL
|
||||
methods: ['GET', 'POST'],
|
||||
credentials: true
|
||||
}
|
||||
: {
|
||||
// Allow multiple origins in development environment
|
||||
origin: [
|
||||
CONFIG.CORS_ORIGIN,
|
||||
/^http:\/\/192\.168\.\d+\.\d+:3000$/, // Match all LAN addresses in the format 192.168.x.x:3000
|
||||
],
|
||||
methods: ['GET', 'POST'],
|
||||
credentials: true
|
||||
};
|
||||
export const corsWSOptions =
|
||||
CONFIG.NODE_ENV === "production"
|
||||
? {
|
||||
origin: CONFIG.CORS_ORIGIN, // Allowed origin, replace with your Next.js application's URL
|
||||
methods: ["GET", "POST"],
|
||||
credentials: true,
|
||||
}
|
||||
: {
|
||||
// Allow multiple origins in development environment
|
||||
origin: [
|
||||
CONFIG.CORS_ORIGIN,
|
||||
/^http:\/\/192\.168\.\d+\.\d+:3000$/, // Match all LAN addresses in the format 192.168.x.x:3000
|
||||
],
|
||||
methods: ["GET", "POST"],
|
||||
credentials: true,
|
||||
};
|
||||
|
||||
@@ -101,7 +101,7 @@ const setTrackHandler: RequestHandler<{}, any, ReferrerTrack> = async (
|
||||
}
|
||||
|
||||
try {
|
||||
const { ref, timestamp, path } = req.body;
|
||||
const { ref, timestamp } = req.body;
|
||||
// Statistics by date
|
||||
const date = new Date(timestamp).toISOString().split("T")[0];
|
||||
const dailyKey = `referrers:daily:${date}`;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Redis Data Structures Used for Rate Limiting:
|
||||
*
|
||||
*
|
||||
* 1. IP Request Timestamps:
|
||||
* - Key Pattern: `ratelimit:join:<ipAddress>` (e.g., "ratelimit:join:192.168.1.100")
|
||||
* - Type: Sorted Set
|
||||
* - Members: Unique identifiers for each request, typically `timestamp-randomNumber`
|
||||
* - Members: Unique identifiers for each request, typically `timestamp-randomNumber`
|
||||
* (e.g., "1678886400000-0.12345"). Using a random suffix ensures
|
||||
* uniqueness if multiple requests occur in the same millisecond.
|
||||
* - Scores: Timestamp of the request (milliseconds since epoch).
|
||||
@@ -17,11 +17,11 @@
|
||||
* - `EXPIRE`: Refreshes/sets the TTL for the key.
|
||||
* - All operations are typically performed within a `pipeline` or `MULTI` for efficiency.
|
||||
*/
|
||||
import { redis } from './redis';
|
||||
import { redis } from "./redis";
|
||||
|
||||
const RATE_LIMIT_PREFIX = 'ratelimit:join:';
|
||||
const RATE_LIMIT_PREFIX = "ratelimit:join:";
|
||||
const RATE_WINDOW = 5; // 5-second time window
|
||||
const RATE_LIMIT = 2; // Maximum number of requests allowed
|
||||
const RATE_LIMIT = 2; // Maximum number of requests allowed
|
||||
|
||||
export async function checkRateLimit(ip: string): Promise<{
|
||||
allowed: boolean;
|
||||
@@ -30,11 +30,11 @@ export async function checkRateLimit(ip: string): Promise<{
|
||||
}> {
|
||||
const key = `${RATE_LIMIT_PREFIX}${ip}`;
|
||||
const now = Date.now();
|
||||
const windowStart = now - (RATE_WINDOW * 1000);
|
||||
const windowStart = now - RATE_WINDOW * 1000;
|
||||
|
||||
// Use Redis's MULTI command to start a transaction
|
||||
const pipeline = redis.pipeline();
|
||||
|
||||
|
||||
// 1. Add current request's timestamp
|
||||
pipeline.zadd(key, now, `${now}-${Math.random()}`); // Add a random suffix to member for uniqueness if multiple requests at same ms
|
||||
// 2. Remove timestamps older than the current window
|
||||
@@ -45,10 +45,10 @@ export async function checkRateLimit(ip: string): Promise<{
|
||||
pipeline.expire(key, RATE_WINDOW);
|
||||
|
||||
const results = await pipeline.exec();
|
||||
|
||||
|
||||
if (!results) {
|
||||
// This case means the pipeline itself failed, not individual commands necessarily
|
||||
console.error('Redis pipeline command failed for rate limiting.');
|
||||
console.error("Redis pipeline command failed for rate limiting.");
|
||||
// Fallback: be lenient or strict? For safety, let's be strict.
|
||||
return { allowed: false, remaining: 0, resetAfter: RATE_WINDOW };
|
||||
}
|
||||
@@ -63,4 +63,4 @@ export async function checkRateLimit(ip: string): Promise<{
|
||||
const resetAfter = RATE_WINDOW - Math.floor((now - windowStart) / 1000);
|
||||
|
||||
return { allowed, remaining, resetAfter };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Redis } from 'ioredis';
|
||||
import { CONFIG } from '../config/env';
|
||||
import { Redis } from "ioredis";
|
||||
import { CONFIG } from "../config/env";
|
||||
// Room prefix and expiration time (seconds)
|
||||
export const ROOM_PREFIX = 'room:';
|
||||
export const SOCKET_PREFIX = 'socket:';
|
||||
export const ROOM_PREFIX = "room:";
|
||||
export const SOCKET_PREFIX = "socket:";
|
||||
export const ROOM_EXPIRY = 3600 * 24; // 24 hours
|
||||
// Redis configuration options
|
||||
const redisConfig = {
|
||||
@@ -16,10 +16,10 @@ const redisConfig = {
|
||||
export const redis = new Redis(redisConfig);
|
||||
|
||||
// Connection event listeners can be added here
|
||||
redis.on('connect', () => {
|
||||
console.log('Redis connected successfully');
|
||||
redis.on("connect", () => {
|
||||
console.log("Redis connected successfully");
|
||||
});
|
||||
|
||||
redis.on('error', (err) => {
|
||||
console.error('Redis connection error:', err);
|
||||
});
|
||||
redis.on("error", (err) => {
|
||||
console.error("Redis connection error:", err);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Redis Data Structures Used:
|
||||
*
|
||||
*
|
||||
* 1. Room Information:
|
||||
* - Key Pattern: `room:<roomId>` (e.g., "room:ABCD12")
|
||||
* - Type: Hash
|
||||
@@ -8,15 +8,15 @@
|
||||
* - `created_at`: Timestamp of room creation.
|
||||
* - TTL: Set by `ROOM_EXPIRY` (e.g., 24 hours), refreshed on activity.
|
||||
* - Operations: HSET (createRoom), HEXISTS (isRoomExist), EXPIRE (createRoom, refreshRoom), DEL (deleteRoom).
|
||||
*
|
||||
*
|
||||
* 2. Sockets in Room:
|
||||
* - Key Pattern: `room:<roomId>:sockets` (e.g., "room:ABCD12:sockets")
|
||||
* - Type: Set
|
||||
* - Members: `socketId`s of clients currently in the room.
|
||||
* - TTL: Matches the corresponding `room:<roomId>` key.
|
||||
* - Operations: SADD (bindSocketToRoom), SREM (unbindSocketFromRoom), SCARD (isRoomEmpty, roomNumOfConnection),
|
||||
* - Operations: SADD (bindSocketToRoom), SREM (unbindSocketFromRoom), SCARD (isRoomEmpty, roomNumOfConnection),
|
||||
* EXPIRE (createRoom, refreshRoom), DEL (as part of deleteRoom).
|
||||
*
|
||||
*
|
||||
* 3. Socket to Room Mapping:
|
||||
* - Key Pattern: `socket:<socketId>` (e.g., "socket:xgACY6QcQCojsOQaAAAB")
|
||||
* - Type: String
|
||||
@@ -24,14 +24,14 @@
|
||||
* - TTL: Set individually upon binding (e.g., `ROOM_EXPIRY` + buffer).
|
||||
* - Operations: SET (with EX option in bindSocketToRoom), GET (getRoomBySocketId), DEL (unbindSocketFromRoom, and as part of deleteRoom).
|
||||
*/
|
||||
import { redis, ROOM_PREFIX, SOCKET_PREFIX, ROOM_EXPIRY } from './redis';
|
||||
import { redis, ROOM_PREFIX, SOCKET_PREFIX, ROOM_EXPIRY } from "./redis";
|
||||
|
||||
const MAX_NUMERIC_ID_ATTEMPTS = 10;
|
||||
const MAX_ALPHANUMERIC_ID_ATTEMPTS = 50;
|
||||
|
||||
// Generate a random 4-digit numeric room ID
|
||||
function generateNumericRoomId(length: number = 4): string {
|
||||
let id = '';
|
||||
let id = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
id += Math.floor(Math.random() * 10).toString();
|
||||
}
|
||||
@@ -39,8 +39,9 @@ function generateNumericRoomId(length: number = 4): string {
|
||||
}
|
||||
// Generate a random 4-character alphanumeric room ID
|
||||
function generateAlphanumericRoomId(length: number = 4): string {
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
const characters =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let result = "";
|
||||
const charactersLength = characters.length;
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += characters.charAt(Math.floor(Math.random() * charactersLength));
|
||||
@@ -49,7 +50,7 @@ function generateAlphanumericRoomId(length: number = 4): string {
|
||||
}
|
||||
// Check if a room exists
|
||||
export async function isRoomExist(roomId: string): Promise<boolean> {
|
||||
return await redis.hexists(ROOM_PREFIX + roomId, 'created_at') === 1; // hset and hexists operate on hashes, 'created_at' is the field name
|
||||
return (await redis.hexists(ROOM_PREFIX + roomId, "created_at")) === 1; // hset and hexists operate on hashes, 'created_at' is the field name
|
||||
}
|
||||
// Create a new room
|
||||
// (Hash)
|
||||
@@ -59,8 +60,9 @@ export async function isRoomExist(roomId: string): Promise<boolean> {
|
||||
export async function createRoom(roomId: string): Promise<void> {
|
||||
const roomKey = ROOM_PREFIX + roomId;
|
||||
const socketsKey = `${roomKey}:sockets`;
|
||||
await redis.multi()
|
||||
.hset(roomKey, 'created_at', Date.now()) // Set hash to store the room's creation time
|
||||
await redis
|
||||
.multi()
|
||||
.hset(roomKey, "created_at", Date.now()) // Set hash to store the room's creation time
|
||||
.expire(roomKey, ROOM_EXPIRY) // Set expiration time
|
||||
.expire(socketsKey, ROOM_EXPIRY)
|
||||
.exec();
|
||||
@@ -70,12 +72,16 @@ export async function deleteRoom(roomId: string): Promise<void> {
|
||||
await redis.del(ROOM_PREFIX + roomId);
|
||||
}
|
||||
// Refresh a room's expiration time
|
||||
export async function refreshRoom(roomId: string, expiry: number = 0): Promise<void> {
|
||||
export async function refreshRoom(
|
||||
roomId: string,
|
||||
expiry: number = 0
|
||||
): Promise<void> {
|
||||
const actualExpiry = expiry > 0 ? expiry : ROOM_EXPIRY;
|
||||
const roomKey = ROOM_PREFIX + roomId;
|
||||
const socketsKey = `${roomKey}:sockets`;
|
||||
console.log(`EXPIRY of roomId:${roomId} is ${actualExpiry}`);
|
||||
await redis.multi()
|
||||
await redis
|
||||
.multi()
|
||||
.expire(roomKey, actualExpiry)
|
||||
.expire(socketsKey, actualExpiry)
|
||||
.exec();
|
||||
@@ -92,47 +98,62 @@ export async function getAvailableRoomId(): Promise<string> {
|
||||
}
|
||||
} while (await isRoomExist(roomId));
|
||||
|
||||
if (attempts > MAX_NUMERIC_ID_ATTEMPTS && await isRoomExist(roomId)) { // Numeric attempts exhausted and last one was not unique
|
||||
console.warn('Numeric room ID attempts exhausted, switching to alphanumeric.');
|
||||
if (attempts > MAX_NUMERIC_ID_ATTEMPTS && (await isRoomExist(roomId))) {
|
||||
// Numeric attempts exhausted and last one was not unique
|
||||
console.warn(
|
||||
"Numeric room ID attempts exhausted, switching to alphanumeric."
|
||||
);
|
||||
attempts = 0; // Reset attempts for alphanumeric
|
||||
do {
|
||||
roomId = generateAlphanumericRoomId(4); // Generate 4-char alphanumeric as requested
|
||||
attempts++;
|
||||
if (attempts > MAX_ALPHANUMERIC_ID_ATTEMPTS) {
|
||||
// This is highly unlikely for 4-char alphanumeric, but as a safeguard:
|
||||
console.error('FATAL: Could not find an available alphanumeric room ID after many attempts.');
|
||||
throw new Error('Failed to generate a unique room ID.');
|
||||
console.error(
|
||||
"FATAL: Could not find an available alphanumeric room ID after many attempts."
|
||||
);
|
||||
throw new Error("Failed to generate a unique room ID.");
|
||||
}
|
||||
} while (await isRoomExist(roomId));
|
||||
}
|
||||
return roomId;
|
||||
}
|
||||
// Bind a socket.id to a room ID
|
||||
export async function bindSocketToRoom(socketId: string, roomId: string): Promise<void> {
|
||||
await redis.multi()
|
||||
export async function bindSocketToRoom(
|
||||
socketId: string,
|
||||
roomId: string
|
||||
): Promise<void> {
|
||||
await redis
|
||||
.multi()
|
||||
// String, stores the room ID associated with this socket ID, e.g., "socket:abcd1234" : "1234"
|
||||
.set(SOCKET_PREFIX + socketId, roomId, 'EX', ROOM_EXPIRY+3600) // Set with expiry
|
||||
.set(SOCKET_PREFIX + socketId, roomId, "EX", ROOM_EXPIRY + 3600) // Set with expiry
|
||||
// Set, list of sockets in the room, e.g., "room:1234:sockets" : ["socket1", "socket2", ...]
|
||||
.sadd(ROOM_PREFIX + roomId + ':sockets', socketId)
|
||||
.sadd(ROOM_PREFIX + roomId + ":sockets", socketId)
|
||||
.exec();
|
||||
}
|
||||
// Get the room ID for a given socket.id
|
||||
export async function getRoomBySocketId(socketId: string): Promise<string | null> {
|
||||
export async function getRoomBySocketId(
|
||||
socketId: string
|
||||
): Promise<string | null> {
|
||||
return await redis.get(SOCKET_PREFIX + socketId);
|
||||
}
|
||||
// Unbind a socket.id from a room ID
|
||||
export async function unbindSocketFromRoom(socketId: string, roomId: string): Promise<void> {
|
||||
await redis.multi()
|
||||
export async function unbindSocketFromRoom(
|
||||
socketId: string,
|
||||
roomId: string
|
||||
): Promise<void> {
|
||||
await redis
|
||||
.multi()
|
||||
.del(SOCKET_PREFIX + socketId) // Unbind socket ID from room ID
|
||||
.srem(ROOM_PREFIX + roomId + ':sockets', socketId) // Remove socket ID from the room's set
|
||||
.srem(ROOM_PREFIX + roomId + ":sockets", socketId) // Remove socket ID from the room's set
|
||||
.exec();
|
||||
}
|
||||
// Check if a room is empty
|
||||
export async function isRoomEmpty(roomId: string): Promise<boolean> {
|
||||
const count = await redis.scard(ROOM_PREFIX + roomId + ':sockets'); // Returns the number of elements in the set
|
||||
const count = await redis.scard(ROOM_PREFIX + roomId + ":sockets"); // Returns the number of elements in the set
|
||||
return count === 0;
|
||||
}
|
||||
// Get the number of connections in a room
|
||||
export async function roomNumOfConnection(roomId: string): Promise<number> {
|
||||
return await redis.scard(ROOM_PREFIX + roomId + ':sockets');
|
||||
}
|
||||
return await redis.scard(ROOM_PREFIX + roomId + ":sockets");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import * as roomService from '../services/room';
|
||||
import { JoinData, SignalingData, InitiatorData, RecipientData } from '../types/socket';
|
||||
import { checkRateLimit } from '../services/rateLimit';
|
||||
import { Server, Socket } from "socket.io";
|
||||
import * as roomService from "../services/room";
|
||||
import {
|
||||
JoinData,
|
||||
SignalingData,
|
||||
InitiatorData,
|
||||
RecipientData,
|
||||
} from "../types/socket";
|
||||
import { checkRateLimit } from "../services/rateLimit";
|
||||
// Room Management:
|
||||
// Use roomId to broadcast messages (socket.to(roomId).emit())
|
||||
// Scenarios: Notifying new user joined, room status updates, etc.
|
||||
@@ -9,49 +14,59 @@ import { checkRateLimit } from '../services/rateLimit';
|
||||
// Use peerId for peer-to-peer communication (socket.to(peerId).emit())
|
||||
// Scenarios: All signaling during WebRTC connection setup, like offer, answer, ice-candidate.
|
||||
export function setupSocketHandlers(io: Server): void {
|
||||
io.on('connection', (socket: Socket) => {
|
||||
console.log('New client connected:', socket.id);
|
||||
io.on("connection", (socket: Socket) => {
|
||||
console.log("New client connected:", socket.id);
|
||||
|
||||
socket.on('join', async (data: JoinData) => {
|
||||
socket.on("join", async (data: JoinData) => {
|
||||
const { roomId: targetRoomId } = data; // Renamed for clarity
|
||||
try {
|
||||
// Get client IP
|
||||
const clientIp = socket.handshake.headers['x-forwarded-for'] ||
|
||||
socket.handshake.address;
|
||||
const clientIp =
|
||||
socket.handshake.headers["x-forwarded-for"] ||
|
||||
socket.handshake.address;
|
||||
// Check rate limit
|
||||
const rateLimitCheck = await checkRateLimit(clientIp as string);
|
||||
if (!rateLimitCheck.allowed) {
|
||||
socket.emit('joinResponse', {
|
||||
socket.emit("joinResponse", {
|
||||
success: false,
|
||||
message: `Rate limit exceeded. Try again in ${rateLimitCheck.resetAfter}s. Attempts left: ${rateLimitCheck.remaining}.`,
|
||||
roomId: targetRoomId
|
||||
roomId: targetRoomId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const targetRoomExists = await roomService.isRoomExist(targetRoomId);
|
||||
if (!targetRoomExists) {
|
||||
socket.emit('joinResponse', { success: false, message: 'Room does not exist', roomId: targetRoomId });
|
||||
socket.emit("joinResponse", {
|
||||
success: false,
|
||||
message: "Room does not exist",
|
||||
roomId: targetRoomId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const existingRoomId = await roomService.getRoomBySocketId(socket.id);
|
||||
if (!existingRoomId) { // Only allow new connection to join if the socket.id is not already in a room
|
||||
if (!existingRoomId) {
|
||||
// Only allow new connection to join if the socket.id is not already in a room
|
||||
socket.join(targetRoomId);
|
||||
console.log(`Client ${socket.id} joined room ${targetRoomId}`);
|
||||
await roomService.bindSocketToRoom(socket.id, targetRoomId);
|
||||
}
|
||||
|
||||
|
||||
await roomService.refreshRoom(targetRoomId);
|
||||
// Notify the user that the join was successful
|
||||
socket.emit('joinResponse', { success: true, message: 'Successfully joined room', roomId: targetRoomId });
|
||||
socket.emit("joinResponse", {
|
||||
success: true,
|
||||
message: "Successfully joined room",
|
||||
roomId: targetRoomId,
|
||||
});
|
||||
// Notify all other users in the room that a new member has joined
|
||||
socket.to(targetRoomId).emit('ready', { peerId: socket.id });
|
||||
socket.to(targetRoomId).emit("ready", { peerId: socket.id });
|
||||
} catch (error) {
|
||||
console.error('Error joining room:', error);
|
||||
socket.emit('joinResponse', {
|
||||
console.error("Error joining room:", error);
|
||||
socket.emit("joinResponse", {
|
||||
success: false,
|
||||
message: 'Server error while joining room',
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
message: "Server error while joining room",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -60,51 +75,53 @@ export function setupSocketHandlers(io: Server): void {
|
||||
// offer: When a client initiates a connection request, it sends an offer to the server, which forwards it to other clients in the same room.
|
||||
// answer: The invited client receives the offer, generates an answer, and sends it back to the initiating client via the server.
|
||||
// ice-candidate: When WebRTC needs to traverse NAT firewalls, it generates ICE candidates. Clients exchange this information through the server to help establish a P2P connection.
|
||||
socket.on('offer', (data: SignalingData) => {
|
||||
socket.to(data.peerId).emit('offer', {
|
||||
socket.on("offer", (data: SignalingData) => {
|
||||
socket.to(data.peerId).emit("offer", {
|
||||
offer: data.offer,
|
||||
from: data.from,
|
||||
peerId: socket.id // Sender's ID
|
||||
peerId: socket.id, // Sender's ID
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('answer', (data: SignalingData) => {
|
||||
socket.to(data.peerId).emit('answer', {
|
||||
socket.on("answer", (data: SignalingData) => {
|
||||
socket.to(data.peerId).emit("answer", {
|
||||
answer: data.answer,
|
||||
from: data.from,
|
||||
peerId: socket.id
|
||||
peerId: socket.id,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('ice-candidate', (data: SignalingData) => {
|
||||
socket.to(data.peerId).emit('ice-candidate', {
|
||||
socket.on("ice-candidate", (data: SignalingData) => {
|
||||
socket.to(data.peerId).emit("ice-candidate", {
|
||||
candidate: data.candidate,
|
||||
from: data.from,
|
||||
peerId: socket.id
|
||||
peerId: socket.id,
|
||||
});
|
||||
});
|
||||
// Handle notification for initiator coming back online -- broadcast to other users in the room
|
||||
socket.on('initiator-online', (data: InitiatorData) => {
|
||||
socket.to(data.roomId).emit('initiator-online', {
|
||||
roomId: data.roomId
|
||||
socket.on("initiator-online", (data: InitiatorData) => {
|
||||
socket.to(data.roomId).emit("initiator-online", {
|
||||
roomId: data.roomId,
|
||||
});
|
||||
});
|
||||
// Handle recipient's response
|
||||
socket.on('recipient-ready', (data: RecipientData) => {
|
||||
socket.to(data.roomId).emit('recipient-ready', {
|
||||
peerId: data.peerId
|
||||
socket.on("recipient-ready", (data: RecipientData) => {
|
||||
socket.to(data.roomId).emit("recipient-ready", {
|
||||
peerId: data.peerId,
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('disconnect', async () => {
|
||||
console.log('Disconnected:', socket.id);
|
||||
socket.on("disconnect", async () => {
|
||||
console.log("Disconnected:", socket.id);
|
||||
const roomId = await roomService.getRoomBySocketId(socket.id);
|
||||
if (roomId) {
|
||||
await roomService.unbindSocketFromRoom(socket.id, roomId);
|
||||
if (await roomService.isRoomEmpty(roomId)) {
|
||||
// await deleteRoom(roomId);
|
||||
await roomService.refreshRoom(roomId, 3600);
|
||||
console.log(`Room ${roomId} is empty and will be deleted in 1 hour due to disconnect.`);
|
||||
console.log(
|
||||
`Room ${roomId} is empty and will be deleted in 1 hour due to disconnect.`
|
||||
);
|
||||
}
|
||||
// Notify other users in the room that this peer has left
|
||||
// io.to(roomId).emit('peer-disconnected', { peerId: socket.id });
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
export interface RoomInfo {
|
||||
created_at: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
|
||||
export interface ReferrerTrack {
|
||||
ref: string;
|
||||
timestamp: number;
|
||||
path: string;
|
||||
ref: string;
|
||||
timestamp: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface LogMessage {
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
||||
message: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ declare global {
|
||||
usernameFragment?: string | null;
|
||||
}
|
||||
|
||||
type RTCSdpType = 'answer' | 'offer' | 'pranswer' | 'rollback';
|
||||
type RTCSdpType = "answer" | "offer" | "pranswer" | "rollback";
|
||||
}
|
||||
|
||||
export interface SignalingData {
|
||||
@@ -34,4 +34,4 @@ export interface InitiatorData {
|
||||
export interface RecipientData {
|
||||
roomId: string;
|
||||
peerId: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
"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/common/YouTubePlayer';
|
||||
import KeyFeatures from '@/components/web/KeyFeatures'
|
||||
import type { Messages } from '@/types/messages';
|
||||
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/common/YouTubePlayer";
|
||||
import KeyFeatures from "@/components/web/KeyFeatures";
|
||||
import type { Messages } from "@/types/messages";
|
||||
|
||||
interface PageContentProps {
|
||||
messages: Messages;
|
||||
lang:string;
|
||||
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";
|
||||
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>
|
||||
<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">
|
||||
<section
|
||||
id="clipboard-app"
|
||||
className="py-12"
|
||||
aria-label="File Transfer Application"
|
||||
>
|
||||
<div className="container mx-auto px-4">
|
||||
{/* sr-only--screen-only: visually hidden */}
|
||||
<h2 className={cn("sr-only", "text-3xl font-bold mb-8 text-center")}>
|
||||
@@ -44,47 +46,47 @@ export default function HomeClient({ messages,lang }: PageContentProps) {
|
||||
{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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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}/>
|
||||
<HowItWorks messages={messages} />
|
||||
</section>
|
||||
{/* System Architecture Section */}
|
||||
<section aria-label="System Architecture">
|
||||
<SystemDiagram messages={messages}/>
|
||||
<SystemDiagram messages={messages} />
|
||||
</section>
|
||||
{/* Key Features */}
|
||||
<section aria-label="Key Features">
|
||||
<KeyFeatures messages={messages}/>
|
||||
<KeyFeatures messages={messages} />
|
||||
</section>
|
||||
{/* FAQ Section */}
|
||||
<section aria-label="Frequently Asked Questions">
|
||||
<FAQSection
|
||||
<FAQSection
|
||||
messages={messages}
|
||||
isMainPage
|
||||
isMainPage
|
||||
titleClassName="text-2xl md:text-3xl"
|
||||
/>
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,41 @@
|
||||
import type { Messages } from '@/types/messages';
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
import { getDictionary } from '@/lib/dictionary';
|
||||
import AboutContent from './AboutContent';
|
||||
import { Metadata } from 'next'
|
||||
import { supportedLocales } from '@/constants/i18n-config';
|
||||
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 }
|
||||
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'),
|
||||
metadataBase: new URL("https://www.securityshare.xyz"),
|
||||
alternates: {
|
||||
canonical: `/${params.lang}/about`,
|
||||
languages: Object.fromEntries(
|
||||
supportedLocales.map(lang => [lang, `/${lang}/about`])
|
||||
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',
|
||||
siteName: "SecureShare",
|
||||
locale: params.lang,
|
||||
type: 'website',
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function About({
|
||||
params: { lang }
|
||||
params: { lang },
|
||||
}: {
|
||||
params: { lang: string }
|
||||
params: { lang: string };
|
||||
}) {
|
||||
const messages = await getDictionary(lang);
|
||||
|
||||
return <AboutContent messages={messages} lang={lang}/>;
|
||||
}
|
||||
|
||||
return <AboutContent messages={messages} lang={lang} />;
|
||||
}
|
||||
|
||||
@@ -1,42 +1,45 @@
|
||||
// app/[lang]/blog/[slug]/metadata.ts
|
||||
import { Metadata } from "next"
|
||||
import { getPostBySlug } from '@/lib/blog'
|
||||
import { generateMetadata as generateBlogMetadata } from '../metadata'
|
||||
import { Metadata } from "next";
|
||||
import { getPostBySlug } from "@/lib/blog";
|
||||
import { generateMetadata as generateBlogMetadata } from "../metadata";
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string; lang: string }
|
||||
params: { slug: string; lang: string };
|
||||
}): Promise<Metadata> {
|
||||
const post = await getPostBySlug(params.slug)
|
||||
|
||||
if (!post) {//blog not found
|
||||
const post = await getPostBySlug(params.slug);
|
||||
|
||||
if (!post) {
|
||||
//blog not found
|
||||
// Call the generateMetadata function of the blog homepage and pass in the parameters
|
||||
return generateBlogMetadata({ params: { lang: params.lang } })
|
||||
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'),
|
||||
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',
|
||||
siteName: "SecureShare",
|
||||
locale: params.lang,
|
||||
type: 'article',
|
||||
type: "article",
|
||||
publishedTime: post.frontmatter.date,
|
||||
modifiedTime: post.frontmatter.date,
|
||||
authors: post.frontmatter.author,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
//Article detail page
|
||||
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'
|
||||
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 { generateMetadata };
|
||||
|
||||
export default async function BlogPost({
|
||||
params,
|
||||
}: {
|
||||
params: { slug: string };
|
||||
}) {
|
||||
const post = await getPostBySlug(params.slug);
|
||||
|
||||
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>Post not found</div>;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
||||
{/* Use md: prefix to handle flex layout for medium screens and above */}
|
||||
@@ -28,10 +32,10 @@ export default async function BlogPost({ params }: { params: { slug: string } })
|
||||
</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'
|
||||
{new Date(post.frontmatter.date).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})}
|
||||
</time>
|
||||
<span className="hidden sm:inline">·</span>
|
||||
@@ -40,7 +44,7 @@ export default async function BlogPost({ params }: { params: { slug: string } })
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
<div className="prose prose-sm sm:prose lg:prose-lg max-w-none">
|
||||
<MDXRemote
|
||||
source={post.content}
|
||||
@@ -50,7 +54,7 @@ export default async function BlogPost({ params }: { params: { slug: string } })
|
||||
<div className="space-y-4 text-gray-700 overflow-x-auto">
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
),
|
||||
}}
|
||||
options={mdxOptions}
|
||||
/>
|
||||
@@ -59,5 +63,5 @@ export default async function BlogPost({ params }: { params: { slug: string } })
|
||||
<TableOfContents content={post.content} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,33 @@
|
||||
import { supportedLocales } from '@/constants/i18n-config';
|
||||
import { Metadata } from 'next'
|
||||
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',
|
||||
}
|
||||
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'
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
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",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
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'
|
||||
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 { generateMetadata };
|
||||
|
||||
export default async function BlogPage({
|
||||
params: { lang }
|
||||
params: { lang },
|
||||
}: {
|
||||
params: { lang: string }
|
||||
params: { lang: string };
|
||||
}) {
|
||||
const posts = await getAllPosts(lang)
|
||||
|
||||
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">
|
||||
@@ -22,7 +22,7 @@ export default async function BlogPage({
|
||||
<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) => (
|
||||
@@ -39,7 +39,7 @@ export default async function BlogPage({
|
||||
<h2 className="text-xl font-bold mb-6">Recent Posts</h2>
|
||||
<div className="space-y-4">
|
||||
{posts.slice(0, 5).map((post) => (
|
||||
<Link
|
||||
<Link
|
||||
key={post.slug}
|
||||
href={`/en/blog/${post.slug}`}
|
||||
className="block hover:text-blue-600 text-base font-medium"
|
||||
@@ -54,7 +54,9 @@ export default async function BlogPage({
|
||||
<h2 className="text-xl font-bold mb-6">Tags</h2>
|
||||
<div className="space-y-3">
|
||||
{/* Get all tags and deduplicate */}
|
||||
{Array.from(new Set(posts.flatMap(p => p.frontmatter.tags))).map((tag) => (
|
||||
{Array.from(
|
||||
new Set(posts.flatMap((p) => p.frontmatter.tags))
|
||||
).map((tag) => (
|
||||
<Link
|
||||
key={tag}
|
||||
href={`/${lang}/blog/tag/${slugifyTag(tag)}`} // Jump to the tag filtering page
|
||||
@@ -62,7 +64,10 @@ export default async function BlogPage({
|
||||
>
|
||||
<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}
|
||||
{
|
||||
posts.filter((p) => p.frontmatter.tags.includes(tag))
|
||||
.length
|
||||
}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
@@ -72,5 +77,5 @@ export default async function BlogPage({
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,47 @@
|
||||
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'
|
||||
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, lang },
|
||||
}: {
|
||||
params: { tag: string; lang: string }
|
||||
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'),
|
||||
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',
|
||||
url: `https://www.securityshare.xyz/${lang}/blog/tag/${encodeURIComponent(
|
||||
tag
|
||||
)}`,
|
||||
siteName: "SecureShare",
|
||||
locale: lang,
|
||||
type: 'website',
|
||||
type: "website",
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
export default async function TagPage({
|
||||
params: { tag, lang }
|
||||
params: { tag, lang },
|
||||
}: {
|
||||
params: { tag: string; lang: string }
|
||||
params: { tag: string; lang: string };
|
||||
}) {
|
||||
const decodedTag = unslugifyTag(tag);
|
||||
const posts = await getPostsByTag(decodedTag,lang)
|
||||
const posts = await getPostsByTag(decodedTag, lang);
|
||||
|
||||
return (
|
||||
<div className="max-w-[1400px] mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
@@ -48,7 +50,9 @@ export default async function TagPage({
|
||||
<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>
|
||||
<p className="text-gray-600 text-lg">
|
||||
Articles tagged with {decodedTag}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Articles List */}
|
||||
@@ -64,5 +68,5 @@ export default async function TagPage({
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import FAQSection from '@/components/web/FAQSection'
|
||||
import FAQSection from "@/components/web/FAQSection";
|
||||
import type { Metadata } from "next";
|
||||
import { getDictionary } from '@/lib/dictionary';
|
||||
import { supportedLocales } from '@/constants/i18n-config';
|
||||
import { getDictionary } from "@/lib/dictionary";
|
||||
import { supportedLocales } from "@/constants/i18n-config";
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: { lang: string }
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: { lang: string };
|
||||
}): Promise<Metadata> {
|
||||
const messages = await getDictionary(params.lang);
|
||||
|
||||
@@ -14,31 +14,29 @@ export async function generateMetadata({
|
||||
title: messages.meta.faq.title,
|
||||
description: messages.meta.faq.description,
|
||||
keywords: messages.meta.faq.keywords,
|
||||
metadataBase: new URL('https://www.securityshare.xyz'),
|
||||
metadataBase: new URL("https://www.securityshare.xyz"),
|
||||
alternates: {
|
||||
canonical: `/${params.lang}/faq`,
|
||||
languages: Object.fromEntries(
|
||||
supportedLocales.map(lang => [lang, `/${lang}/faq`])
|
||||
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',
|
||||
siteName: "SecureShare",
|
||||
locale: params.lang,
|
||||
type: 'website',
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function FAQ({
|
||||
params: { lang }
|
||||
params: { lang },
|
||||
}: {
|
||||
params: { lang: string }
|
||||
params: { lang: string };
|
||||
}) {
|
||||
const messages = await getDictionary(lang);
|
||||
return (
|
||||
<FAQSection messages={messages} />
|
||||
)
|
||||
}
|
||||
return <FAQSection messages={messages} />;
|
||||
}
|
||||
|
||||
@@ -1,43 +1,63 @@
|
||||
import type { Messages } from '@/types/messages';
|
||||
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>
|
||||
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>
|
||||
);
|
||||
}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
import { getDictionary } from '@/lib/dictionary';
|
||||
import HelpContent from './HelpContent';
|
||||
import { Metadata } from 'next'
|
||||
import { supportedLocales } from '@/constants/i18n-config';
|
||||
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 }
|
||||
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'),
|
||||
metadataBase: new URL("https://www.securityshare.xyz"),
|
||||
alternates: {
|
||||
canonical: `/${params.lang}/help`,
|
||||
languages: Object.fromEntries(
|
||||
supportedLocales.map(lang => [lang, `/${lang}/help`])
|
||||
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',
|
||||
siteName: "SecureShare",
|
||||
locale: params.lang,
|
||||
type: 'website',
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
export default async function Help({
|
||||
params: { lang }
|
||||
params: { lang },
|
||||
}: {
|
||||
params: { lang: string }
|
||||
params: { lang: string };
|
||||
}) {
|
||||
const messages = await getDictionary(lang);
|
||||
return <HelpContent messages={messages} lang={lang} />;
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import HomeClient from './HomeClient';
|
||||
import { getDictionary } from '@/lib/dictionary';
|
||||
import { Metadata } from 'next'
|
||||
import { supportedLocales } from '@/constants/i18n-config';
|
||||
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 }
|
||||
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'),
|
||||
metadataBase: new URL("https://www.securityshare.xyz"),
|
||||
alternates: {
|
||||
canonical: `/${params.lang}`,
|
||||
languages: Object.fromEntries(
|
||||
supportedLocales.map(lang => [lang, `/${lang}`])
|
||||
supportedLocales.map((lang) => [lang, `/${lang}`])
|
||||
),
|
||||
},
|
||||
//OpenGraph metadata can optimize social media sharing
|
||||
@@ -26,19 +26,19 @@ export async function generateMetadata({
|
||||
title: messages.meta.home.title,
|
||||
description: messages.meta.home.description,
|
||||
url: `https://www.securityshare.xyz/${params.lang}`,
|
||||
siteName: 'SecureShare',
|
||||
siteName: "SecureShare",
|
||||
locale: params.lang,
|
||||
type: 'website',
|
||||
type: "website",
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Home({
|
||||
params: { lang }
|
||||
params: { lang },
|
||||
}: {
|
||||
params: { lang: string }
|
||||
params: { lang: string };
|
||||
}) {
|
||||
const messages = await getDictionary(lang);
|
||||
|
||||
return <HomeClient messages={messages} lang={lang}/>;
|
||||
}
|
||||
|
||||
return <HomeClient messages={messages} lang={lang} />;
|
||||
}
|
||||
|
||||
@@ -1,36 +1,35 @@
|
||||
import type { Messages } from '@/types/messages';
|
||||
import type { Messages } from "@/types/messages";
|
||||
|
||||
interface PageContentProps {
|
||||
messages: Messages;
|
||||
}
|
||||
|
||||
export default function PrivacyContent({ messages }: PageContentProps){
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>.
|
||||
{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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getDictionary } from '@/lib/dictionary';
|
||||
import PrivacyContent from './PrivacyContent';
|
||||
import { supportedLocales } from '@/constants/i18n-config';
|
||||
import { getDictionary } from "@/lib/dictionary";
|
||||
import PrivacyContent from "./PrivacyContent";
|
||||
import { supportedLocales } from "@/constants/i18n-config";
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
}: {
|
||||
params: { lang: string }
|
||||
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'),
|
||||
metadataBase: new URL("https://www.securityshare.xyz"),
|
||||
alternates: {
|
||||
canonical: `/${params.lang}/privacy`,
|
||||
languages: Object.fromEntries(
|
||||
supportedLocales.map(lang => [lang, `/${lang}/privacy`])
|
||||
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',
|
||||
siteName: "SecureShare",
|
||||
locale: params.lang,
|
||||
type: 'website',
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
export default async function Privacy({
|
||||
params: { lang }
|
||||
params: { lang },
|
||||
}: {
|
||||
params: { lang: string }
|
||||
params: { lang: string };
|
||||
}) {
|
||||
const messages = await getDictionary(lang);
|
||||
return <PrivacyContent messages={messages} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,26 @@
|
||||
import type { Messages } from '@/types/messages';
|
||||
import type { Messages } from "@/types/messages";
|
||||
|
||||
interface PageContentProps {
|
||||
messages: Messages;
|
||||
}
|
||||
|
||||
export default function TermsContent({ messages }: PageContentProps){
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<p className="text-lg mb-4">{messages.text.terms.h2_5_P}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
import { getDictionary } from '@/lib/dictionary';
|
||||
import TermsContent from './TermsContent';
|
||||
import { Metadata } from 'next'
|
||||
import { supportedLocales } from '@/constants/i18n-config';
|
||||
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 }
|
||||
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'),
|
||||
metadataBase: new URL("https://www.securityshare.xyz"),
|
||||
alternates: {
|
||||
canonical: `/${params.lang}/terms`,
|
||||
languages: Object.fromEntries(
|
||||
supportedLocales.map(lang => [lang, `/${lang}/terms`])
|
||||
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',
|
||||
siteName: "SecureShare",
|
||||
locale: params.lang,
|
||||
type: 'website',
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
}
|
||||
export default async function TermsOfUse({
|
||||
params: { lang }
|
||||
params: { lang },
|
||||
}: {
|
||||
params: { lang: string }
|
||||
params: { lang: string };
|
||||
}) {
|
||||
const messages = await getDictionary(lang);
|
||||
return <TermsContent messages={messages} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,10 +76,10 @@ export const checkRoom = async (roomId: string): Promise<boolean> => {
|
||||
};
|
||||
|
||||
// Set tracking information
|
||||
export const setTrack = async (ref: string, path: string) => {
|
||||
export const setTrack = async (ref: string) => {
|
||||
const options = getFetchOptions({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ref, path, timestamp: new Date().toISOString() }),
|
||||
body: JSON.stringify({ ref, timestamp: new Date().toISOString() }),
|
||||
});
|
||||
return apiCall<void>(API_ROUTES.set_track, options);
|
||||
};
|
||||
|
||||
+19
-19
@@ -1,32 +1,32 @@
|
||||
import { MetadataRoute } from 'next'
|
||||
import { supportedLocales } from '@/constants/i18n-config';
|
||||
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 baseUrl = "https://www.securityshare.xyz";
|
||||
const languages = supportedLocales;
|
||||
const routes = ['', '/about', '/help', '/faq', '/terms', '/privacy']
|
||||
|
||||
const urls: MetadataRoute.Sitemap = []
|
||||
|
||||
const routes = ["", "/about", "/help", "/faq", "/terms", "/privacy"];
|
||||
|
||||
const urls: MetadataRoute.Sitemap = [];
|
||||
|
||||
// Add root URL
|
||||
urls.push({
|
||||
url: baseUrl,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'daily',
|
||||
changeFrequency: "daily",
|
||||
priority: 1,
|
||||
})
|
||||
|
||||
});
|
||||
|
||||
// Add language specific URLs
|
||||
languages.forEach(lang => {
|
||||
routes.forEach(route => {
|
||||
languages.forEach((lang) => {
|
||||
routes.forEach((route) => {
|
||||
urls.push({
|
||||
url: `${baseUrl}/${lang}${route}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: route === '' ? 1.0 : 0.8,
|
||||
})
|
||||
})
|
||||
})
|
||||
changeFrequency: "weekly",
|
||||
priority: route === "" ? 1.0 : 0.8,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return urls
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import React, { useState,useEffect} from 'react';
|
||||
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';
|
||||
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;
|
||||
@@ -17,23 +22,27 @@ const FileTransferButton = ({
|
||||
onRequest,
|
||||
isCurrentFileTransferring,
|
||||
isOtherFileTransferring,
|
||||
isSavedToDisk
|
||||
isSavedToDisk,
|
||||
}: FileTransferButtonProps) => {
|
||||
const locale = useLocale();
|
||||
const [messages, setMessages] = useState<Messages | null>(null);
|
||||
// Button status judgment
|
||||
const isDisabled = isCurrentFileTransferring || isSavedToDisk || isOtherFileTransferring;
|
||||
const isDisabled =
|
||||
isCurrentFileTransferring || isSavedToDisk || isOtherFileTransferring;
|
||||
|
||||
useEffect(() => {
|
||||
getDictionary(locale)
|
||||
.then(dict => setMessages(dict))
|
||||
.catch(error => console.error('Failed to load messages:', error));
|
||||
.then((dict) => setMessages(dict))
|
||||
.catch((error) => console.error("Failed to load messages:", error));
|
||||
}, [locale]);
|
||||
// Display different tooltips based on status
|
||||
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;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -42,24 +51,25 @@ const FileTransferButton = ({
|
||||
if (isSavedToDisk) {
|
||||
return {
|
||||
variant: "ghost" as const,
|
||||
className: "mr-2 text-gray-500"
|
||||
className: "mr-2 text-gray-500",
|
||||
};
|
||||
}
|
||||
if (isCurrentFileTransferring) {
|
||||
return {
|
||||
variant: "outline" as const,
|
||||
className: "mr-2 cursor-not-allowed"
|
||||
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"
|
||||
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"
|
||||
className: "mr-2 hover:bg-blue-50",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -72,22 +82,28 @@ const FileTransferButton = ({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block">
|
||||
<Button
|
||||
onClick={onRequest}
|
||||
variant={buttonStyles.variant}
|
||||
size="sm"
|
||||
className={buttonStyles.className}
|
||||
disabled={isDisabled}
|
||||
<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}
|
||||
<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"
|
||||
<TooltipContent
|
||||
side="top"
|
||||
className="bg-gray-800 text-white px-3 py-2 rounded-md text-sm"
|
||||
>
|
||||
{getTooltipContent()}
|
||||
@@ -97,4 +113,4 @@ const FileTransferButton = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default FileTransferButton;
|
||||
export default FileTransferButton;
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import React, { useState, useEffect, ChangeEvent, useRef, useCallback } from 'react';
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
ChangeEvent,
|
||||
useRef,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Upload } from 'lucide-react';
|
||||
import {FileMeta,CustomFile } from '@/types/webrtc';
|
||||
import { Upload } from "lucide-react";
|
||||
import { FileMeta, CustomFile } from "@/types/webrtc";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
} from "@/components/ui/dialog";
|
||||
// Add this declaration at the top of the file to extend existing types and avoid IDE errors
|
||||
declare module "@/components/ui/input" {
|
||||
interface InputProps {
|
||||
@@ -17,19 +23,25 @@ declare module "@/components/ui/input" {
|
||||
}
|
||||
}
|
||||
|
||||
import { getDictionary } from '@/lib/dictionary';
|
||||
import { useLocale } from '@/hooks/useLocale';
|
||||
import type { Messages } from '@/types/messages';
|
||||
import { en } from '@/constants/messages/en'; // Import English dictionary as default
|
||||
import { getDictionary } from "@/lib/dictionary";
|
||||
import { useLocale } from "@/hooks/useLocale";
|
||||
import type { Messages } from "@/types/messages";
|
||||
import { en } from "@/constants/messages/en"; // Import English dictionary as default
|
||||
|
||||
const traverseFileTree = async (item: FileSystemEntry, path = ''): Promise<CustomFile[]> => {
|
||||
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] });
|
||||
const customFile: CustomFile = Object.assign(file, {
|
||||
fullName: path + file.name,
|
||||
folderName: path.split("/")[0],
|
||||
});
|
||||
resolve([customFile]);
|
||||
});
|
||||
} else if (item.isDirectory) {
|
||||
@@ -42,7 +54,7 @@ const traverseFileTree = async (item: FileSystemEntry, path = ''): Promise<Custo
|
||||
entries = entries.concat(Array.from(results));
|
||||
readEntries();
|
||||
} else {
|
||||
const newPath = path + item.name + '/';
|
||||
const newPath = path + item.name + "/";
|
||||
const subResults = await Promise.all(
|
||||
entries.map((entry) => traverseFileTree(entry, newPath))
|
||||
);
|
||||
@@ -60,77 +72,97 @@ const traverseFileTree = async (item: FileSystemEntry, path = ''): Promise<Custo
|
||||
};
|
||||
|
||||
function formatFileChosen(
|
||||
template: string,
|
||||
fileNum: number,
|
||||
template: string,
|
||||
fileNum: number,
|
||||
folderNum: number
|
||||
) {
|
||||
return template.replace('{fileNum}', fileNum.toString())
|
||||
.replace('{folderNum}', folderNum.toString());
|
||||
return template
|
||||
.replace("{fileNum}", fileNum.toString())
|
||||
.replace("{folderNum}", folderNum.toString());
|
||||
}
|
||||
|
||||
interface FileUploadHandlerProps {
|
||||
onFilePicked: (files: CustomFile[]) => void;
|
||||
}
|
||||
|
||||
const FileUploadHandler: React.FC<FileUploadHandlerProps> = ({
|
||||
onFilePicked
|
||||
}) => {
|
||||
const FileUploadHandler: React.FC<FileUploadHandlerProps> = ({
|
||||
onFilePicked,
|
||||
}) => {
|
||||
const locale = useLocale();
|
||||
const [messages, setMessages] = useState<Messages>(en); // Use English dictionary as initial value
|
||||
|
||||
const dropZoneRef = useRef<HTMLDivElement>(null);// Drag and drop files to attachments -- support
|
||||
|
||||
const dropZoneRef = useRef<HTMLDivElement>(null); // Drag and drop files to attachments -- support
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
// File selector -- message prompt
|
||||
const [fileText, setFileText] = useState<string>(en.text.fileUploadHandler.NoFileChosen_tips);
|
||||
const [fileText, setFileText] = useState<string>(
|
||||
en.text.fileUploadHandler.NoFileChosen_tips
|
||||
);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (locale !== 'en') { // Only load other language packs if not English
|
||||
if (locale !== "en") {
|
||||
// Only load other language packs if not English
|
||||
getDictionary(locale)
|
||||
.then(dict => {setMessages(dict);setFileText(dict.text.fileUploadHandler.NoFileChosen_tips);})
|
||||
.catch(error => console.error('Failed to load messages:', error));
|
||||
.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[]) => {
|
||||
const handleFileChange = useCallback(
|
||||
(newFiles: CustomFile[]) => {
|
||||
// console.log(newFiles);
|
||||
onFilePicked(newFiles);
|
||||
|
||||
const fileNum = newFiles.length;
|
||||
const folderNum = newFiles.filter(file => file.folderName).length;
|
||||
|
||||
const folderNum = newFiles.filter((file) => file.folderName).length;
|
||||
|
||||
const choose_dis = formatFileChosen(
|
||||
messages!.text.fileUploadHandler.fileChosen_tips_template, fileNum, folderNum
|
||||
messages!.text.fileUploadHandler.fileChosen_tips_template,
|
||||
fileNum,
|
||||
folderNum
|
||||
);
|
||||
|
||||
|
||||
setFileText(choose_dis);
|
||||
setTimeout(() => setFileText(messages!.text.fileUploadHandler.NoFileChosen_tips), 2000);
|
||||
setTimeout(
|
||||
() => setFileText(messages!.text.fileUploadHandler.NoFileChosen_tips),
|
||||
2000
|
||||
);
|
||||
// Reset the file input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}, [messages,onFilePicked]);
|
||||
},
|
||||
[messages, onFilePicked]
|
||||
);
|
||||
// Drag and drop folder upload response processing
|
||||
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
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 => {
|
||||
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]);
|
||||
},
|
||||
[handleFileChange]
|
||||
);
|
||||
/* Define a callback function handleDragOver to handle the drag-over event.
|
||||
In handleDragOver, prevent default behavior and event propagation to ensure custom handling.
|
||||
There is no dependency array, which means the handleDragOver function will only be created once when the component first renders, and will not be re-created in subsequent renders.
|
||||
@@ -140,35 +172,47 @@ const FileUploadHandler: React.FC<FileUploadHandlerProps> = ({
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
// Click to upload file processing
|
||||
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);
|
||||
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); // Close the dialog
|
||||
}
|
||||
handleFileChange(files2);
|
||||
setIsModalOpen(false);// Close the dialog
|
||||
}
|
||||
}, [handleFileChange]);
|
||||
},
|
||||
[handleFileChange]
|
||||
);
|
||||
// Click to upload folder response processing
|
||||
const handleFolderInputChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const files_ = Array.from(e.target.files);
|
||||
let files:CustomFile[] = [];
|
||||
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);
|
||||
});
|
||||
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);// Close the dialog
|
||||
}
|
||||
}, [handleFileChange]);
|
||||
handleFileChange(files);
|
||||
setIsModalOpen(false); // Close the dialog
|
||||
}
|
||||
},
|
||||
[handleFileChange]
|
||||
);
|
||||
|
||||
// Handle drag and drop area click
|
||||
const handleZoneClick = () => {
|
||||
@@ -194,13 +238,13 @@ const FileUploadHandler: React.FC<FileUploadHandlerProps> = ({
|
||||
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>
|
||||
>
|
||||
<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"
|
||||
@@ -228,7 +272,7 @@ const FileUploadHandler: React.FC<FileUploadHandlerProps> = ({
|
||||
{messages.text.fileUploadHandler.chosenDiagTitle}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="mt-2 text-muted-foreground">
|
||||
{messages.text.fileUploadHandler.chosenDiagDescription}
|
||||
{messages.text.fileUploadHandler.chosenDiagDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-center gap-4 mt-6">
|
||||
@@ -251,4 +295,4 @@ const FileUploadHandler: React.FC<FileUploadHandlerProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export { FileUploadHandler };
|
||||
export { FileUploadHandler };
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
import React from 'react';
|
||||
import {Progress } from '@/types/webrtc';
|
||||
import React from "react";
|
||||
import { Progress } from "@/types/webrtc";
|
||||
|
||||
interface TransferProgressProps {
|
||||
message: string;
|
||||
progress: Progress;
|
||||
}
|
||||
// Display 'Sending' or 'Receiving' message
|
||||
const TransferProgress: React.FC<TransferProgressProps> = ({ message, progress }) => {
|
||||
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
|
||||
{(speed / 1024).toFixed(2)} MB/s
|
||||
</span>
|
||||
<span className="inline-block min-w-[50px] text-right">
|
||||
{(progress.progress * 100).toFixed(0).padStart(2, '0')}%
|
||||
{(progress.progress * 100).toFixed(0).padStart(2, "0")}%
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export function Divider() {
|
||||
return <div className="w-px h-6 bg-gray-300" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AlignLeft, AlignCenter, AlignRight } from 'lucide-react';
|
||||
import { AlignmentType } from '../types';
|
||||
import { AlignLeft, AlignCenter, AlignRight } from "lucide-react";
|
||||
import { AlignmentType } from "../types";
|
||||
|
||||
interface AlignmentToolsProps {
|
||||
alignText: (alignment: AlignmentType) => void;
|
||||
@@ -10,25 +10,25 @@ export function AlignmentTools({ alignText }: AlignmentToolsProps) {
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
className="p-1.5 hover:bg-gray-200 rounded"
|
||||
onClick={() => alignText('left')}
|
||||
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')}
|
||||
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')}
|
||||
onClick={() => alignText("right")}
|
||||
title="Align right"
|
||||
>
|
||||
<AlignRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,44 @@
|
||||
import { Bold, Italic, Underline } from 'lucide-react';
|
||||
import { FormatType } from '../types';
|
||||
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) {
|
||||
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')}
|
||||
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')}
|
||||
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')}
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Type, Palette } from 'lucide-react';
|
||||
import { SelectMenu } from '../components/SelectMenu';
|
||||
import { StyleOption,FontStyleType } from '../types';
|
||||
import { Type, Palette } from "lucide-react";
|
||||
import { SelectMenu } from "../components/SelectMenu";
|
||||
import { StyleOption, FontStyleType } from "../types";
|
||||
|
||||
interface FontToolsProps {
|
||||
fontFamilies: StyleOption[];
|
||||
@@ -9,30 +9,35 @@ interface FontToolsProps {
|
||||
setFontStyle: (property: FontStyleType, value: string) => void;
|
||||
}
|
||||
|
||||
export function FontTools({ fontFamilies, fontSizes, colors, setFontStyle }: FontToolsProps) {
|
||||
export function FontTools({
|
||||
fontFamilies,
|
||||
fontSizes,
|
||||
colors,
|
||||
setFontStyle,
|
||||
}: FontToolsProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<SelectMenu
|
||||
options={fontFamilies}
|
||||
onChange={(value) => setFontStyle('family', value)}
|
||||
onChange={(value) => setFontStyle("family", value)}
|
||||
icon={Type}
|
||||
placeholder="Font"
|
||||
className="text-sm"
|
||||
/>
|
||||
<SelectMenu
|
||||
options={fontSizes}
|
||||
onChange={(value) => setFontStyle('size', value)}
|
||||
onChange={(value) => setFontStyle("size", value)}
|
||||
icon={Type}
|
||||
placeholder="Size"
|
||||
className="text-sm w-16"
|
||||
/>
|
||||
<SelectMenu
|
||||
options={colors}
|
||||
onChange={(value) => setFontStyle('color', value)}
|
||||
onChange={(value) => setFontStyle("color", value)}
|
||||
icon={Palette}
|
||||
placeholder="Color"
|
||||
className="text-sm w-20"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link2, Image, Code } from 'lucide-react';
|
||||
import { Link2, Image, Code } from "lucide-react";
|
||||
|
||||
interface InsertToolsProps {
|
||||
insertLink: () => void;
|
||||
@@ -6,7 +6,11 @@ interface InsertToolsProps {
|
||||
insertCodeBlock: () => void;
|
||||
}
|
||||
|
||||
export function InsertTools({ insertLink, insertImage, insertCodeBlock }: InsertToolsProps) {
|
||||
export function InsertTools({
|
||||
insertLink,
|
||||
insertImage,
|
||||
insertCodeBlock,
|
||||
}: InsertToolsProps) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<button
|
||||
@@ -32,4 +36,4 @@ export function InsertTools({ insertLink, insertImage, insertCodeBlock }: Insert
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
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';
|
||||
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 RichTextEditor: React.FC<EditorProps> = ({ onChange, value = "" }) => {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const [html, setHtml] = useState(value);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
@@ -35,7 +35,8 @@ const RichTextEditor: React.FC<EditorProps> = ({ onChange, value = '' }) => {
|
||||
const handleChange = useCallback(() => {
|
||||
if (editorRef.current) {
|
||||
const content = (editorRef.current as HTMLDivElement).innerHTML;
|
||||
if (content !== html) {// If the content has not changed, do not trigger an update
|
||||
if (content !== html) {
|
||||
// If the content has not changed, do not trigger an update
|
||||
isInternalChange.current = true;
|
||||
setHtml(content);
|
||||
onChange(content);
|
||||
@@ -49,64 +50,76 @@ const RichTextEditor: React.FC<EditorProps> = ({ onChange, value = '' }) => {
|
||||
setFontStyle,
|
||||
insertLink,
|
||||
insertImage,
|
||||
insertCodeBlock
|
||||
insertCodeBlock,
|
||||
} = useEditorCommands(editorRef, handleChange);
|
||||
|
||||
const getSelection = useSelection();
|
||||
const { findStyleParent } = useStyleManagement(editorRef);
|
||||
// Check the style of the currently selected text
|
||||
const isStyleActive = useCallback((style: string): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const selectionInfo = getSelection();
|
||||
if (!selectionInfo || !selectionInfo.selection.toString()) return false;
|
||||
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 node = selectionInfo.selection.anchorNode;
|
||||
if (!node) return false;
|
||||
|
||||
const handlePaste = useCallback((e: CustomClipboardEvent) => {
|
||||
// Handle image pasting
|
||||
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);
|
||||
const styleParent = findStyleParent(node as DOMNodeWithStyle, style);
|
||||
return !!styleParent;
|
||||
},
|
||||
[findStyleParent, getSelection]
|
||||
);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
(e: CustomClipboardEvent) => {
|
||||
// Handle image pasting
|
||||
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;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle plain text
|
||||
e.preventDefault();
|
||||
const text = e.clipboardData.getData('text/plain');
|
||||
if (typeof document !== 'undefined') {
|
||||
document.execCommand('insertText', false, text);
|
||||
}
|
||||
}, [getSelection, handleChange]);
|
||||
|
||||
// Handle plain text
|
||||
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>;
|
||||
@@ -118,34 +131,34 @@ const RichTextEditor: React.FC<EditorProps> = ({ onChange, value = '' }) => {
|
||||
{/* Toolbar - Add light gray background and bottom border */}
|
||||
<div className="flex flex-wrap gap-1 p-2 bg-gray-50 border-b">
|
||||
{/* Basic format tool group */}
|
||||
<BasicFormatTools
|
||||
isStyleActive={isStyleActive}
|
||||
formatText={formatText}
|
||||
<BasicFormatTools
|
||||
isStyleActive={isStyleActive}
|
||||
formatText={formatText}
|
||||
/>
|
||||
<Divider />
|
||||
|
||||
|
||||
{/* Font-related selector group */}
|
||||
<FontTools
|
||||
<FontTools
|
||||
fontFamilies={fontFamilies}
|
||||
fontSizes={fontSizes}
|
||||
colors={colors}
|
||||
setFontStyle={setFontStyle}
|
||||
/>
|
||||
<Divider />
|
||||
|
||||
|
||||
{/* Alignment tool group */}
|
||||
<AlignmentTools alignText={alignText} />
|
||||
<Divider />
|
||||
|
||||
|
||||
{/* Insert tool group */}
|
||||
<InsertTools
|
||||
<InsertTools
|
||||
insertLink={insertLink}
|
||||
insertImage={insertImage}
|
||||
insertCodeBlock={insertCodeBlock}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Editor area - Add pure white background and inner shadow effect */}
|
||||
{/* Editor area - Add pure white background and inner shadow effect */}
|
||||
<div
|
||||
ref={editorRef}
|
||||
className="p-4 min-h-[200px] md:min-h-[400px] focus:outline-none bg-white shadow-inner"
|
||||
@@ -159,4 +172,4 @@ const RichTextEditor: React.FC<EditorProps> = ({ onChange, value = '' }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default RichTextEditor;
|
||||
export default RichTextEditor;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
import { SelectMenuProps } from '../types';
|
||||
import React from "react";
|
||||
import { SelectMenuProps } from "../types";
|
||||
// Dropdown selection component
|
||||
export const SelectMenu: React.FC<SelectMenuProps> = ({
|
||||
options,
|
||||
onChange,
|
||||
icon: Icon,
|
||||
placeholder,
|
||||
className
|
||||
export const SelectMenu: React.FC<SelectMenuProps> = ({
|
||||
options,
|
||||
onChange,
|
||||
icon: Icon,
|
||||
placeholder,
|
||||
className,
|
||||
}) => (
|
||||
<div className="relative inline-block">
|
||||
<select
|
||||
@@ -24,4 +24,4 @@ export const SelectMenu: React.FC<SelectMenuProps> = ({
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
export const styleMap = {
|
||||
'bold': 'fontWeight',
|
||||
'italic': 'fontStyle',
|
||||
'underline': 'textDecoration'
|
||||
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' }
|
||||
{ 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' }
|
||||
{ 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' }
|
||||
];
|
||||
{ label: "Black", value: "#000000" },
|
||||
{ label: "Red", value: "#FF0000" },
|
||||
{ label: "Green", value: "#008000" },
|
||||
{ label: "Blue", value: "#0000FF" },
|
||||
];
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
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';
|
||||
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
|
||||
@@ -13,184 +19,207 @@ export const useEditorCommands = (
|
||||
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 formatText = useCallback(
|
||||
(format: FormatType) => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const { selection, range } = selectionInfo;
|
||||
|
||||
const styleParent = findStyleParent(selection.anchorNode as DOMNodeWithStyle, styleMap[format]);
|
||||
const selectionInfo = getSelection();
|
||||
if (!selectionInfo || !selectionInfo.selection.toString()) return;
|
||||
|
||||
if (styleParent) {
|
||||
// Remove style
|
||||
removeStyle(styleParent, format);
|
||||
} else {
|
||||
// Add style
|
||||
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;
|
||||
}
|
||||
const { selection, range } = selectionInfo;
|
||||
|
||||
// If the selected content is within a span and that span does not have the target style, add the style directly
|
||||
const parentElement = selection.anchorNode?.parentElement;
|
||||
if (parentElement &&
|
||||
parentElement.tagName === 'SPAN' &&
|
||||
!(parentElement as StyledElement).style[styleMap[format]] &&
|
||||
parentElement !== editorRef.current) {
|
||||
const styleParent = findStyleParent(
|
||||
selection.anchorNode as DOMNodeWithStyle,
|
||||
styleMap[format]
|
||||
);
|
||||
|
||||
(parentElement as StyledElement).style[styleMap[format]] = span.style[styleMap[format]];
|
||||
|
||||
if (styleParent) {
|
||||
// Remove style
|
||||
removeStyle(styleParent, format);
|
||||
} else {
|
||||
// Otherwise, create a new span
|
||||
span.appendChild(range.extractContents());
|
||||
range.insertNode(span);
|
||||
// Add style
|
||||
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;
|
||||
}
|
||||
|
||||
// If the selected content is within a span and that span does not have the target style, add the style directly
|
||||
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 {
|
||||
// Otherwise, create a new span
|
||||
span.appendChild(range.extractContents());
|
||||
range.insertNode(span);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Maintain selection
|
||||
const newRange = document.createRange();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
|
||||
// Update HTML
|
||||
handleChange();
|
||||
}, [findStyleParent, getSelection, removeStyle]);
|
||||
// Maintain selection
|
||||
const newRange = document.createRange();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
|
||||
// Update 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;
|
||||
|
||||
// Find the current text node or its container
|
||||
let textNode = selectionInfo.selection.anchorNode as DOMNodeWithStyle;
|
||||
|
||||
// If it is a text node, get its parent element
|
||||
if (textNode.nodeType === 3) {
|
||||
textNode = textNode.parentElement as DOMNodeWithStyle;
|
||||
}
|
||||
const alignText = useCallback(
|
||||
(alignment: AlignmentType) => {
|
||||
if (!editorRef.current || typeof window === "undefined") return;
|
||||
|
||||
// Search outwards for the outermost style container (e.g., a span with color or size)
|
||||
let outerContainer = textNode;
|
||||
while (
|
||||
outerContainer.parentElement &&
|
||||
outerContainer.parentElement !== editorRef.current &&
|
||||
(outerContainer.parentElement as HTMLElement).tagName === 'SPAN'
|
||||
) {
|
||||
outerContainer = outerContainer.parentElement as DOMNodeWithStyle;
|
||||
}
|
||||
const selectionInfo = getSelection();
|
||||
if (!selectionInfo) return;
|
||||
|
||||
// Create or find a div container to handle alignment
|
||||
let alignmentContainer: HTMLElement;
|
||||
if (
|
||||
outerContainer.parentElement === editorRef.current ||
|
||||
(outerContainer.parentElement as HTMLElement).tagName !== 'DIV'
|
||||
) {
|
||||
// A new alignment container needs to be created
|
||||
alignmentContainer = document.createElement('div');
|
||||
alignmentContainer.style.textAlign = alignment;
|
||||
// Wrap existing content
|
||||
outerContainer.parentElement?.insertBefore(alignmentContainer, outerContainer);
|
||||
alignmentContainer.appendChild(outerContainer);
|
||||
} else {
|
||||
// Use the existing alignment container
|
||||
alignmentContainer = outerContainer.parentElement as HTMLElement;
|
||||
alignmentContainer.style.textAlign = alignment;
|
||||
}
|
||||
|
||||
// Update HTML
|
||||
handleChange();
|
||||
}, [getSelection]);
|
||||
// Find the current text node or its container
|
||||
let textNode = selectionInfo.selection.anchorNode as DOMNodeWithStyle;
|
||||
|
||||
// If it is a text node, get its parent element
|
||||
if (textNode.nodeType === 3) {
|
||||
textNode = textNode.parentElement as DOMNodeWithStyle;
|
||||
}
|
||||
|
||||
// Search outwards for the outermost style container (e.g., a span with color or size)
|
||||
let outerContainer = textNode;
|
||||
while (
|
||||
outerContainer.parentElement &&
|
||||
outerContainer.parentElement !== editorRef.current &&
|
||||
(outerContainer.parentElement as HTMLElement).tagName === "SPAN"
|
||||
) {
|
||||
outerContainer = outerContainer.parentElement as DOMNodeWithStyle;
|
||||
}
|
||||
|
||||
// Create or find a div container to handle alignment
|
||||
let alignmentContainer: HTMLElement;
|
||||
if (
|
||||
outerContainer.parentElement === editorRef.current ||
|
||||
(outerContainer.parentElement as HTMLElement).tagName !== "DIV"
|
||||
) {
|
||||
// A new alignment container needs to be created
|
||||
alignmentContainer = document.createElement("div");
|
||||
alignmentContainer.style.textAlign = alignment;
|
||||
// Wrap existing content
|
||||
outerContainer.parentElement?.insertBefore(
|
||||
alignmentContainer,
|
||||
outerContainer
|
||||
);
|
||||
alignmentContainer.appendChild(outerContainer);
|
||||
} else {
|
||||
// Use the existing alignment container
|
||||
alignmentContainer = outerContainer.parentElement as HTMLElement;
|
||||
alignmentContainer.style.textAlign = alignment;
|
||||
}
|
||||
|
||||
// Update 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;
|
||||
// Map style type to actual CSS property name
|
||||
const stylePropertyMap = {
|
||||
'family': 'fontFamily',
|
||||
'size': 'fontSize',
|
||||
'color': 'color'
|
||||
};
|
||||
const styleProperty = stylePropertyMap[type];
|
||||
// Get the range of the selected content
|
||||
const rangeContent = range.cloneContents();
|
||||
// Check if the selected content contains block-level <p> / <div> elements
|
||||
const containsBlock = Array.from(rangeContent.childNodes).some(
|
||||
node => node.nodeType === 1 && ['P', 'DIV'].includes((node as HTMLElement).tagName)
|
||||
);
|
||||
|
||||
if (containsBlock) {
|
||||
// If the selected content includes block-level elements, iterate through and process the text within each block-level element
|
||||
const blocks = Array.from(rangeContent.childNodes).filter(
|
||||
node => node.nodeType === 1 && ['P', 'DIV'].includes((node as HTMLElement).tagName)
|
||||
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;
|
||||
// Map style type to actual CSS property name
|
||||
const stylePropertyMap = {
|
||||
family: "fontFamily",
|
||||
size: "fontSize",
|
||||
color: "color",
|
||||
};
|
||||
const styleProperty = stylePropertyMap[type];
|
||||
// Get the range of the selected content
|
||||
const rangeContent = range.cloneContents();
|
||||
// Check if the selected content contains block-level <p> / <div> elements
|
||||
const containsBlock = Array.from(rangeContent.childNodes).some(
|
||||
(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
|
||||
|
||||
if (containsBlock) {
|
||||
// If the selected content includes block-level elements, iterate through and process the text within each block-level element
|
||||
const blocks = Array.from(rangeContent.childNodes).filter(
|
||||
(node) =>
|
||||
node.nodeType === 1 &&
|
||||
["P", "DIV"].includes((node as HTMLElement).tagName)
|
||||
);
|
||||
let node;
|
||||
while (node = walker.nextNode()) {
|
||||
textNodes.push(node);
|
||||
}
|
||||
|
||||
textNodes.forEach(textNode => {
|
||||
// Check if the parent element is already a 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);
|
||||
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) => {
|
||||
// Check if the parent element is already a 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
// Clear the original content and insert new content
|
||||
range.deleteContents();
|
||||
range.insertNode(rangeContent);
|
||||
} else {
|
||||
// If it's plain text, use the original logic
|
||||
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;
|
||||
}
|
||||
// Clear the original content and insert new content
|
||||
range.deleteContents();
|
||||
range.insertNode(rangeContent);
|
||||
} else {
|
||||
// Otherwise, create a new span
|
||||
const span = document.createElement('span') as StyledElement;
|
||||
span.style[styleProperty] = value;
|
||||
span.appendChild(range.extractContents());
|
||||
range.insertNode(span);
|
||||
// If it's plain text, use the original logic
|
||||
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 {
|
||||
// Otherwise, create a new span
|
||||
const span = document.createElement("span") as StyledElement;
|
||||
span.style[styleProperty] = value;
|
||||
span.appendChild(range.extractContents());
|
||||
range.insertNode(span);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Maintain selection
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
handleChange();
|
||||
}, [getSelection, findStyleParent, cleanupSpan]);
|
||||
|
||||
// Maintain selection
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
|
||||
handleChange();
|
||||
},
|
||||
[getSelection, findStyleParent, cleanupSpan]
|
||||
);
|
||||
|
||||
// Insert link
|
||||
const insertLink = useCallback(() => {
|
||||
@@ -200,27 +229,30 @@ export const useEditorCommands = (
|
||||
// If there is selected text, use the selected text as the link text
|
||||
text = selection.toString();
|
||||
}
|
||||
|
||||
|
||||
// Use a prompt to separate the link and text with a space
|
||||
const input = prompt('Please enter the link address and text (separated by space):', `https:// ${text}`);
|
||||
const input = prompt(
|
||||
"Please enter the link address and text (separated by space):",
|
||||
`https:// ${text}`
|
||||
);
|
||||
|
||||
if (input) {
|
||||
// Split the input to get the url and text
|
||||
const [url, ...textParts] = input.split(' ');
|
||||
const text = textParts.join(' '); // Handle cases where the text may contain spaces
|
||||
|
||||
const [url, ...textParts] = input.split(" ");
|
||||
const text = textParts.join(" "); // Handle cases where the text may contain spaces
|
||||
|
||||
if (url && text) {
|
||||
const selectionInfo = getSelection();
|
||||
if (!selectionInfo) return;
|
||||
|
||||
|
||||
const { range } = selectionInfo;
|
||||
const link = document.createElement('a');
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.textContent = text;
|
||||
link.target = '_blank';
|
||||
link.style.color = '#0066cc';
|
||||
link.style.textDecoration = 'underline';
|
||||
|
||||
link.target = "_blank";
|
||||
link.style.color = "#0066cc";
|
||||
link.style.textDecoration = "underline";
|
||||
|
||||
range.deleteContents();
|
||||
range.insertNode(link);
|
||||
handleChange();
|
||||
@@ -230,9 +262,9 @@ export const useEditorCommands = (
|
||||
|
||||
// Insert image
|
||||
const insertImage = useCallback(() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
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];
|
||||
@@ -240,15 +272,15 @@ export const useEditorCommands = (
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event: ProgressEvent<FileReader>) => {
|
||||
if (!event.target || !event.target.result) return;
|
||||
const img = document.createElement('img');
|
||||
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';
|
||||
|
||||
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);
|
||||
@@ -262,26 +294,26 @@ export const useEditorCommands = (
|
||||
|
||||
// Insert code block
|
||||
const insertCodeBlock = useCallback(() => {
|
||||
const code = prompt('insert code:');
|
||||
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';
|
||||
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);
|
||||
@@ -294,6 +326,6 @@ export const useEditorCommands = (
|
||||
setFontStyle,
|
||||
insertLink,
|
||||
insertImage,
|
||||
insertCodeBlock
|
||||
insertCodeBlock,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useCallback } from 'react';
|
||||
import { SelectionInfo } from '../types';
|
||||
import { useCallback } from "react";
|
||||
import { SelectionInfo } from "../types";
|
||||
|
||||
export const useSelection = () => {
|
||||
// Get selection
|
||||
return useCallback((): SelectionInfo | null => {
|
||||
if (typeof window === 'undefined') return 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 };
|
||||
}, []);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,49 +1,57 @@
|
||||
import { useCallback } from 'react';
|
||||
import { DOMNodeWithStyle, StyledElement } from '../types';
|
||||
import { useCallback } from "react";
|
||||
import { DOMNodeWithStyle, StyledElement } from "../types";
|
||||
|
||||
export const useStyleManagement = (editorRef: React.RefObject<HTMLDivElement>) => {
|
||||
export const useStyleManagement = (
|
||||
editorRef: React.RefObject<HTMLDivElement>
|
||||
) => {
|
||||
// Find the nearest parent element with the specified style
|
||||
const findStyleParent = useCallback((node: DOMNodeWithStyle, styleType: string): StyledElement | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
let current = node;
|
||||
// If the current node is a text node, start searching from its parent 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;
|
||||
}
|
||||
const findStyleParent = useCallback(
|
||||
(node: DOMNodeWithStyle, styleType: string): StyledElement | null => {
|
||||
if (typeof window === "undefined") return null;
|
||||
let current = node;
|
||||
// If the current node is a text node, start searching from its parent node
|
||||
if (current.nodeType === 3) {
|
||||
current = current.parentElement as DOMNodeWithStyle;
|
||||
}
|
||||
current = current.parentElement as DOMNodeWithStyle;
|
||||
}
|
||||
return null;
|
||||
}, [editorRef]);
|
||||
// Clean up empty span tags or those with only inherited values
|
||||
const cleanupSpan = useCallback((span: StyledElement | null) => {
|
||||
// First, check if the span exists
|
||||
if (!span) return;
|
||||
|
||||
// Then check if editorRef.current exists and compare
|
||||
// Modify the comparison logic, using HTMLElement as a common base class for comparison
|
||||
if (editorRef.current && span.contains(editorRef.current)) return;
|
||||
// Check if there are only inherit values or no styles
|
||||
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);
|
||||
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;
|
||||
}
|
||||
parent.removeChild(span);
|
||||
}
|
||||
}, [editorRef]);
|
||||
return null;
|
||||
},
|
||||
[editorRef]
|
||||
);
|
||||
// Clean up empty span tags or those with only inherited values
|
||||
const cleanupSpan = useCallback(
|
||||
(span: StyledElement | null) => {
|
||||
// First, check if the span exists
|
||||
if (!span) return;
|
||||
|
||||
// Then check if editorRef.current exists and compare
|
||||
// Modify the comparison logic, using HTMLElement as a common base class for comparison
|
||||
if (editorRef.current && span.contains(editorRef.current)) return;
|
||||
// Check if there are only inherit values or no styles
|
||||
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 };
|
||||
};
|
||||
};
|
||||
|
||||
@@ -20,16 +20,17 @@ export interface SelectionInfo {
|
||||
}
|
||||
|
||||
// Style format type
|
||||
export type FormatType = 'bold' | 'italic' | 'underline';
|
||||
export type FormatType = "bold" | "italic" | "underline";
|
||||
|
||||
// Alignment type
|
||||
export type AlignmentType = 'left' | 'center' | 'right';
|
||||
export type AlignmentType = "left" | "center" | "right";
|
||||
|
||||
// Font style type
|
||||
export type FontStyleType = 'family' | 'size' | 'color';
|
||||
export type FontStyleType = "family" | "size" | "color";
|
||||
|
||||
// Paste event handler function type
|
||||
export interface CustomClipboardEvent extends React.ClipboardEvent<HTMLDivElement> {
|
||||
export interface CustomClipboardEvent
|
||||
extends React.ClipboardEvent<HTMLDivElement> {
|
||||
clipboardData: DataTransfer;
|
||||
}
|
||||
|
||||
@@ -56,4 +57,4 @@ export interface DOMNodeWithStyle extends Node {
|
||||
export interface EditorProps {
|
||||
onChange: (html: string) => void;
|
||||
value?: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ export const handleImageUpload = (
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event: ProgressEvent<FileReader>) => {
|
||||
if (!event.target || !event.target.result) return;
|
||||
const img = document.createElement('img');
|
||||
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';
|
||||
img.style.maxWidth = "100%";
|
||||
img.style.height = "auto";
|
||||
img.style.margin = "10px 0";
|
||||
onSuccess(img);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { FormatType, StyledElement } from '../types';
|
||||
import { styleMap } from '../constants';
|
||||
import { FormatType, StyledElement } from "../types";
|
||||
import { styleMap } from "../constants";
|
||||
// Remove style
|
||||
export const removeStyle = (element: StyledElement, style: FormatType) => {
|
||||
element.style[styleMap[style]] = '';// Remove the specified style
|
||||
element.style[styleMap[style]] = ""; // Remove the specified style
|
||||
// If the span has no other styles, remove the span tag
|
||||
if (element.tagName === 'SPAN' && !element.getAttribute('style')) {
|
||||
if (element.tagName === "SPAN" && !element.getAttribute("style")) {
|
||||
const parent = element.parentNode;
|
||||
while (element.firstChild) {
|
||||
parent.insertBefore(element.firstChild, element);
|
||||
}
|
||||
parent.removeChild(element);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import React from 'react';
|
||||
import { Globe } from 'lucide-react';
|
||||
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';
|
||||
} 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('/');
|
||||
const segments = pathname.split("/");
|
||||
segments[1] = locale;
|
||||
router.push(segments.join('/'));
|
||||
router.push(segments.join("/"));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -30,17 +30,17 @@ const LanguageSwitcher = () => {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{i18n.locales.map((locale) => (
|
||||
<DropdownMenuItem
|
||||
key={locale}
|
||||
onClick={() => switchLanguage(locale)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
key={locale}
|
||||
onClick={() => switchLanguage(locale)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{languageDisplayNames[locale]}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageSwitcher;
|
||||
export default LanguageSwitcher;
|
||||
|
||||
@@ -23,13 +23,13 @@
|
||||
// </Tooltip>
|
||||
// </TooltipProvider>
|
||||
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
import {
|
||||
Tooltip as TooltipRoot,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
type TooltipProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -37,17 +37,15 @@ type TooltipProps = {
|
||||
delayDuration?: number;
|
||||
};
|
||||
|
||||
export const Tooltip: React.FC<TooltipProps> = ({
|
||||
children,
|
||||
content,
|
||||
delayDuration = 200
|
||||
export const Tooltip: React.FC<TooltipProps> = ({
|
||||
children,
|
||||
content,
|
||||
delayDuration = 200,
|
||||
}) => {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipRoot delayDuration={delayDuration}>
|
||||
<TooltipTrigger asChild>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent className="whitespace-pre-line bg-primary text-primary-foreground text-xs">
|
||||
{content}
|
||||
</TooltipContent>
|
||||
@@ -56,4 +54,4 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default Tooltip;
|
||||
export default Tooltip;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { type BlogPost } from '@/lib/blog'
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { type BlogPost } from "@/lib/blog";
|
||||
|
||||
interface ArticleListItemProps {
|
||||
post: BlogPost
|
||||
post: BlogPost;
|
||||
}
|
||||
|
||||
export function ArticleListItem({ post }: ArticleListItemProps) {
|
||||
@@ -19,7 +19,7 @@ export function ArticleListItem({ post }: ArticleListItemProps) {
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="p-8">
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500 mb-4">
|
||||
<time className="font-medium">
|
||||
@@ -28,8 +28,8 @@ export function ArticleListItem({ post }: ArticleListItemProps) {
|
||||
<span>·</span>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{post.frontmatter.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
<span
|
||||
key={tag}
|
||||
className="bg-gray-100 px-3 py-1 rounded-full hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
{tag}
|
||||
@@ -42,27 +42,39 @@ export function ArticleListItem({ post }: ArticleListItemProps) {
|
||||
{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
|
||||
<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" />
|
||||
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>
|
||||
<span className="text-sm">
|
||||
by <span className="font-bold">{post.frontmatter.author}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,86 @@
|
||||
import Image from 'next/image'
|
||||
import { ComponentProps, DetailedHTMLProps, HTMLAttributes } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Image from "next/image";
|
||||
import { ComponentProps, DetailedHTMLProps, HTMLAttributes } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
// Dynamically import the Mermaid component
|
||||
const Mermaid = dynamic(() => import('@/components/blog/Mermaid'), { ssr: false });
|
||||
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 }>
|
||||
}
|
||||
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>
|
||||
<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
|
||||
@@ -41,7 +89,7 @@ export const mdxComponents: MDXComponents = {
|
||||
width={800}
|
||||
height={400}
|
||||
className="rounded-lg w-full"
|
||||
alt={props.alt || ''}
|
||||
alt={props.alt || ""}
|
||||
/>
|
||||
{props.alt && (
|
||||
<div className="text-center text-sm text-gray-600 mt-2 italic">
|
||||
@@ -52,14 +100,20 @@ export const mdxComponents: MDXComponents = {
|
||||
);
|
||||
},
|
||||
pre: ({ children, ...props }) => (
|
||||
<pre className="relative my-6 rounded-lg bg-gray-50 border border-gray-200 p-4 overflow-x-auto" {...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}>
|
||||
<code
|
||||
className="bg-gray-50 rounded px-1.5 py-0.5 text-gray-800 border border-gray-200 text-sm"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
@@ -70,7 +124,10 @@ export const mdxComponents: MDXComponents = {
|
||||
},
|
||||
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}>
|
||||
<table
|
||||
className="min-w-full divide-y divide-gray-300 border border-gray-300"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
@@ -91,33 +148,42 @@ export const mdxComponents: MDXComponents = {
|
||||
</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"
|
||||
<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"
|
||||
<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}>
|
||||
<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}>
|
||||
<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}>
|
||||
<ol
|
||||
className="list-decimal list-outside ml-6 my-6 space-y-2 text-gray-700"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
@@ -127,5 +193,4 @@ export const mdxComponents: MDXComponents = {
|
||||
</li>
|
||||
),
|
||||
mermaid: Mermaid, // Use the defined Mermaid component
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
'use client' // Mark as client component
|
||||
"use client"; // Mark as client component
|
||||
|
||||
import mermaid from 'mermaid'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import mermaid from "mermaid";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
// Initialize Mermaid.js
|
||||
mermaid.initialize({ startOnLoad: false })
|
||||
mermaid.initialize({ startOnLoad: false });
|
||||
|
||||
const Mermaid: React.FC<{ children: string }> = ({ children }) => {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
mermaid.init(undefined, ref.current)
|
||||
mermaid.init(undefined, ref.current);
|
||||
}
|
||||
}, [children])
|
||||
}, [children]);
|
||||
|
||||
return <div ref={ref} className="mermaid">{children}</div>
|
||||
}
|
||||
return (
|
||||
<div ref={ref} className="mermaid">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Mermaid
|
||||
export default Mermaid;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import React, { useEffect, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface TocItem {
|
||||
id: string;
|
||||
@@ -12,18 +12,21 @@ interface TableOfContentsProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const TableOfContents: React.FC<TableOfContentsProps> = ({ content }) => {
|
||||
const [activeId, setActiveId] = useState<string>('');
|
||||
export const TableOfContents: React.FC<TableOfContentsProps> = ({
|
||||
content,
|
||||
}) => {
|
||||
const [activeId, setActiveId] = useState<string>("");
|
||||
const [toc, setToc] = useState<TocItem[]>([]);
|
||||
|
||||
// Generate a valid ID, preserving Chinese characters
|
||||
const generateValidId = (text: string): string => {
|
||||
return encodeURIComponent(text
|
||||
.trim() // Remove leading/trailing spaces
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/\-\-+/g, '-') // Replace multiple hyphens with a single one
|
||||
.replace(/^-+/, '') // Remove leading hyphens
|
||||
.replace(/-+$/, '') // Remove trailing hyphens
|
||||
return encodeURIComponent(
|
||||
text
|
||||
.trim() // Remove leading/trailing spaces
|
||||
.replace(/\s+/g, "-") // Replace spaces with hyphens
|
||||
.replace(/\-\-+/g, "-") // Replace multiple hyphens with a single one
|
||||
.replace(/^-+/, "") // Remove leading hyphens
|
||||
.replace(/-+$/, "") // Remove trailing hyphens
|
||||
);
|
||||
};
|
||||
|
||||
@@ -38,7 +41,7 @@ export const TableOfContents: React.FC<TableOfContentsProps> = ({ content }) =>
|
||||
const level = match[1].length;
|
||||
const text = match[2].trim();
|
||||
let id = generateValidId(text);
|
||||
|
||||
|
||||
// If ID already exists, add a numeric suffix
|
||||
let counter = 1;
|
||||
let uniqueId = id;
|
||||
@@ -46,7 +49,7 @@ export const TableOfContents: React.FC<TableOfContentsProps> = ({ content }) =>
|
||||
uniqueId = `${id}-${counter}`;
|
||||
counter++;
|
||||
}
|
||||
|
||||
|
||||
usedIds.add(uniqueId);
|
||||
items.push({ id: uniqueId, text, level });
|
||||
}
|
||||
@@ -63,19 +66,19 @@ export const TableOfContents: React.FC<TableOfContentsProps> = ({ content }) =>
|
||||
}
|
||||
});
|
||||
},
|
||||
{ rootMargin: '-80px 0px -40% 0px' }
|
||||
{ rootMargin: "-80px 0px -40% 0px" }
|
||||
);
|
||||
|
||||
// Ensure all headings are rendered
|
||||
const setupObserver = () => {
|
||||
const headers = document.querySelectorAll('h1[id], h2[id], h3[id]');
|
||||
const headers = document.querySelectorAll("h1[id], h2[id], h3[id]");
|
||||
headers.forEach((header) => observer.observe(header));
|
||||
};
|
||||
|
||||
// Ensure DOM is updated
|
||||
if (toc.length > 0) {
|
||||
// Give the DOM some time to update
|
||||
setTimeout(setupObserver, 100);
|
||||
setTimeout(setupObserver, 100);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
@@ -87,14 +90,15 @@ export const TableOfContents: React.FC<TableOfContentsProps> = ({ content }) =>
|
||||
if (element) {
|
||||
// Get element position
|
||||
const rect = element.getBoundingClientRect();
|
||||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||||
|
||||
const scrollTop =
|
||||
window.pageYOffset || document.documentElement.scrollTop;
|
||||
|
||||
// Calculate target position (considering the fixed navigation bar height, assuming 80px)
|
||||
const offsetTop = rect.top + scrollTop - 80;
|
||||
|
||||
window.scrollTo({
|
||||
top: offsetTop,
|
||||
behavior: 'smooth'
|
||||
behavior: "smooth",
|
||||
});
|
||||
|
||||
// Set current active item
|
||||
@@ -112,17 +116,17 @@ export const TableOfContents: React.FC<TableOfContentsProps> = ({ content }) =>
|
||||
<li
|
||||
key={item.id}
|
||||
className={clsx(
|
||||
'transition-all',
|
||||
item.level === 1 ? 'ml-0' : item.level === 2 ? 'ml-4' : 'ml-8'
|
||||
"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',
|
||||
"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'
|
||||
? "text-blue-600 font-medium"
|
||||
: "text-gray-600"
|
||||
)}
|
||||
>
|
||||
{item.text}
|
||||
@@ -132,4 +136,4 @@ export const TableOfContents: React.FC<TableOfContentsProps> = ({ content }) =>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
// The pop-up dialog will appear automatically when conditions are met, and ensures it only appears once.
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface AutoPopupDialogProps {
|
||||
// Unique identifier for localStorage
|
||||
@@ -32,11 +32,11 @@ export function AutoPopupDialog({
|
||||
useEffect(() => {
|
||||
// Check if it has been shown before using localStorage
|
||||
const hasShown = localStorage.getItem(storageKey);
|
||||
|
||||
|
||||
if (!hasShown && condition()) {
|
||||
setOpen(true);
|
||||
// Mark as shown
|
||||
localStorage.setItem(storageKey, 'true');
|
||||
localStorage.setItem(storageKey, "true");
|
||||
}
|
||||
}, [storageKey, condition]);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Play } from 'lucide-react';
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Play } from "lucide-react";
|
||||
|
||||
interface YouTubePlayerProps {
|
||||
videoId: string;
|
||||
@@ -9,12 +9,12 @@ interface YouTubePlayerProps {
|
||||
|
||||
const YouTubePlayer: React.FC<YouTubePlayerProps> = ({
|
||||
videoId,
|
||||
className = ''
|
||||
className = "",
|
||||
}) => {
|
||||
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||
const [thumbnailLoaded, setThumbnailLoaded] = useState<boolean>(false);
|
||||
const [currentThumbnail, setCurrentThumbnail] = useState<string>('');
|
||||
|
||||
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`;
|
||||
@@ -34,39 +34,39 @@ const YouTubePlayer: React.FC<YouTubePlayerProps> = ({
|
||||
return (
|
||||
<div className={`relative w-full max-w-5xl mx-auto ${className}`}>
|
||||
<div className="relative pb-[56.25%]">
|
||||
{!isPlaying ? (
|
||||
{!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
|
||||
{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"
|
||||
/>
|
||||
)}
|
||||
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;
|
||||
export default YouTubePlayer;
|
||||
|
||||
@@ -1,25 +1,35 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AnimatedButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
interface AnimatedButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
onClick?: () => Promise<void> | void;
|
||||
loadingText?: string;
|
||||
icon?: React.ReactNode;
|
||||
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
||||
variant?:
|
||||
| "default"
|
||||
| "destructive"
|
||||
| "outline"
|
||||
| "secondary"
|
||||
| "ghost"
|
||||
| "link";
|
||||
}
|
||||
|
||||
const AnimatedButton = React.forwardRef<HTMLButtonElement, AnimatedButtonProps>(
|
||||
({
|
||||
children,
|
||||
onClick,
|
||||
className,
|
||||
loadingText,
|
||||
icon,
|
||||
variant = 'default',
|
||||
disabled,
|
||||
...props
|
||||
}, ref) => {
|
||||
(
|
||||
{
|
||||
children,
|
||||
onClick,
|
||||
className,
|
||||
loadingText,
|
||||
icon,
|
||||
variant = "default",
|
||||
disabled,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
@@ -38,8 +48,8 @@ const AnimatedButton = React.forwardRef<HTMLButtonElement, AnimatedButtonProps>(
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
className={cn(
|
||||
'transition-transform duration-200',
|
||||
isAnimating ? 'scale-95' : '',
|
||||
"transition-transform duration-200",
|
||||
isAnimating ? "scale-95" : "",
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
@@ -53,13 +63,15 @@ const AnimatedButton = React.forwardRef<HTMLButtonElement, AnimatedButtonProps>(
|
||||
}
|
||||
);
|
||||
|
||||
AnimatedButton.displayName = 'AnimatedButton';
|
||||
AnimatedButton.displayName = "AnimatedButton";
|
||||
|
||||
export default AnimatedButton;
|
||||
// 使用示例
|
||||
{/* <AnimatedButton
|
||||
{
|
||||
/* <AnimatedButton
|
||||
onClick={handleShare}
|
||||
loadingText="Sending..."
|
||||
>
|
||||
Start sending
|
||||
</AnimatedButton> */}
|
||||
</AnimatedButton> */
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion"
|
||||
import type { Messages } from '@/types/messages';
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import type { Messages } from "@/types/messages";
|
||||
|
||||
interface FAQMessage {
|
||||
[key: string]: string;
|
||||
@@ -18,30 +18,33 @@ interface FAQ {
|
||||
const generateFAQs = (messages: { text: { faqs: FAQMessage } }): FAQ[] => {
|
||||
const faqs: FAQ[] = [];
|
||||
const faqsData = messages.text.faqs;
|
||||
|
||||
|
||||
// Get the total number of questions (by finding keys starting with question_)
|
||||
const questionKeys = Object.keys(faqsData).filter(key => key.startsWith('question_'));
|
||||
|
||||
const questionKeys = Object.keys(faqsData).filter((key) =>
|
||||
key.startsWith("question_")
|
||||
);
|
||||
|
||||
// Automatically generate FAQ array based on the number of questions
|
||||
questionKeys.forEach(qKey => {
|
||||
const index = qKey.split('_')[1]; // Get the numeric index
|
||||
questionKeys.forEach((qKey) => {
|
||||
const index = qKey.split("_")[1]; // Get the numeric index
|
||||
const aKey = `answer_${index}`;
|
||||
|
||||
if (faqsData[aKey]) { // Ensure the corresponding answer exists
|
||||
|
||||
if (faqsData[aKey]) {
|
||||
// Ensure the corresponding answer exists
|
||||
faqs.push({
|
||||
question: faqsData[qKey],
|
||||
answer: faqsData[aKey]
|
||||
answer: faqsData[aKey],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return faqs;
|
||||
};
|
||||
|
||||
interface FAQSectionProps {
|
||||
isMainPage?: boolean; // Whether it is the FAQ section of the main page
|
||||
className?: string; // Allow passing custom className
|
||||
showTitle?: boolean; // Whether to display the title
|
||||
isMainPage?: boolean; // Whether it is the FAQ section of the main page
|
||||
className?: string; // Allow passing custom className
|
||||
showTitle?: boolean; // Whether to display the title
|
||||
titleClassName?: string; // Title style class
|
||||
lang?: string;
|
||||
messages: Messages;
|
||||
@@ -52,11 +55,10 @@ export default function FAQSection({
|
||||
className = "",
|
||||
showTitle = true,
|
||||
titleClassName = "",
|
||||
messages
|
||||
messages,
|
||||
}: FAQSectionProps) {
|
||||
|
||||
const faqs = generateFAQs(messages);
|
||||
|
||||
|
||||
// Set default styles for different scenarios
|
||||
const containerClasses = `container mx-auto px-4 py-8 ${className}`;
|
||||
const defaultTitleClasses = "font-bold mb-8";
|
||||
@@ -64,13 +66,16 @@ export default function FAQSection({
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{showTitle && (
|
||||
isMainPage ? (
|
||||
<h2 className={`text-3xl ${titleClasses}`}>{messages.text.faqs.FAQ_dis}</h2>
|
||||
{showTitle &&
|
||||
(isMainPage ? (
|
||||
<h2 className={`text-3xl ${titleClasses}`}>
|
||||
{messages.text.faqs.FAQ_dis}
|
||||
</h2>
|
||||
) : (
|
||||
<h1 className={`text-4xl ${titleClasses}`}>{messages.text.faqs.FAQ_dis}</h1>
|
||||
)
|
||||
)}
|
||||
<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}`}>
|
||||
@@ -80,16 +85,16 @@ export default function FAQSection({
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
// // On the standalone FAQ page
|
||||
// <FAQSection /> // Use h1 tag
|
||||
|
||||
// // On the home page
|
||||
// <FAQSection
|
||||
// isMainPage
|
||||
// <FAQSection
|
||||
// isMainPage
|
||||
// titleClassName="text-2xl md:text-3xl" // Optional: use a slightly smaller font size on the home page
|
||||
// /> // Use h2 tag
|
||||
|
||||
// // If you don't need to display the title
|
||||
// <FAQSection showTitle={false} />
|
||||
// <FAQSection showTitle={false} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { Messages } from '@/types/messages'
|
||||
import { languageDisplayNames } from '@/constants/i18n-config';
|
||||
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;
|
||||
@@ -15,16 +15,17 @@ export function Footer({ messages, lang }: FooterProps) {
|
||||
<div className="flex flex-col sm:flex-row justify-between items-center space-y-4 sm:space-y-0">
|
||||
{/* Left: Logo and copyright information */}
|
||||
<div className="flex items-center">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="SecureShare Logo"
|
||||
width={30}
|
||||
height={30}
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="SecureShare Logo"
|
||||
width={30}
|
||||
height={30}
|
||||
className="mr-2"
|
||||
priority
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
© {new Date().getFullYear()} {messages.text.Footer.CopyrightNotice}
|
||||
© {new Date().getFullYear()}{" "}
|
||||
{messages.text.Footer.CopyrightNotice}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -33,16 +34,16 @@ export function Footer({ messages, lang }: FooterProps) {
|
||||
<ul className="flex flex-wrap justify-center gap-4">
|
||||
{/* Terms and Privacy Policy */}
|
||||
<li>
|
||||
<Link
|
||||
href={`/${lang}/terms`}
|
||||
<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`}
|
||||
<Link
|
||||
href={`/${lang}/privacy`}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{messages.text.Footer.Privacy_dis}
|
||||
@@ -73,4 +74,4 @@ export function Footer({ messages, lang }: FooterProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
export default Footer;
|
||||
|
||||
@@ -1,41 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Image from 'next/image'
|
||||
import type { Messages } from '@/types/messages';
|
||||
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){
|
||||
|
||||
export default function HowItWorks({ messages }: PageContentProps) {
|
||||
const steps = [
|
||||
{
|
||||
number: 1,
|
||||
title: messages!.text.HowItWorks.step1_title,
|
||||
description: messages!.text.HowItWorks.step1_description
|
||||
description: messages!.text.HowItWorks.step1_description,
|
||||
},
|
||||
{
|
||||
number: 2,
|
||||
title: messages!.text.HowItWorks.step2_title,
|
||||
description: messages!.text.HowItWorks.step2_description
|
||||
description: messages!.text.HowItWorks.step2_description,
|
||||
},
|
||||
{
|
||||
number: 3,
|
||||
title: messages!.text.HowItWorks.step3_title,
|
||||
description: messages!.text.HowItWorks.step3_description
|
||||
}
|
||||
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>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
@@ -52,8 +51,10 @@ export default function HowItWorks({ messages }: PageContentProps){
|
||||
{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">
|
||||
<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>
|
||||
@@ -70,10 +71,17 @@ export default function HowItWorks({ messages }: PageContentProps){
|
||||
<div className="w-full md:w-1/2">
|
||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
{/* The default Next.js image optimizer does not support handling of GIF animations */}
|
||||
<Image src="/HowItWorks.gif" alt="How SecureShare Works" unoptimized width={700} height={921} className="mx-auto mb-6" />
|
||||
<Image
|
||||
src="/HowItWorks.gif"
|
||||
alt="How SecureShare Works"
|
||||
unoptimized
|
||||
width={700}
|
||||
height={921}
|
||||
className="mx-auto mb-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,52 +1,53 @@
|
||||
import Image from 'next/image'
|
||||
import type { Messages } from '@/types/messages';
|
||||
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>
|
||||
<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>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types"
|
||||
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>
|
||||
}
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
|
||||
+224
-173
@@ -1,220 +1,270 @@
|
||||
import { Messages } from '@/types/messages'
|
||||
import { Messages } from "@/types/messages";
|
||||
|
||||
export const ja: Messages = {
|
||||
meta: {
|
||||
home: {
|
||||
title: "SecureShare: 無料P2Pファイル&クリップボード共有 | プライベート&アップロード不要",
|
||||
description: "SecureShareは、サイズ制限や登録なしで即座に安全なP2Pファイル共有を可能にします。テキスト、画像、フォルダをエンドツーエンド暗号化でデバイス間で共有。チームコラボレーションやプライベートファイル転送に最適です。",
|
||||
keywords: 'ファイル共有, 安全なファイル転送, P2Pファイル転送, webrtcファイル共有, プライベートクリップボード, チームコラボレーション, クロスデバイス共有, 暗号化ファイル転送, 登録不要ファイル共有, 無制限ファイル転送, フォルダ同期, モバイルファイル転送, 安全なメッセージング, 即時ファイル共有, プライベートデータ転送',
|
||||
title:
|
||||
"SecureShare: 無料P2Pファイル&クリップボード共有 | プライベート&アップロード不要",
|
||||
description:
|
||||
"SecureShareは、サイズ制限や登録なしで即座に安全なP2Pファイル共有を可能にします。テキスト、画像、フォルダをエンドツーエンド暗号化でデバイス間で共有。チームコラボレーションやプライベートファイル転送に最適です。",
|
||||
keywords:
|
||||
"ファイル共有, 安全なファイル転送, P2Pファイル転送, webrtcファイル共有, プライベートクリップボード, チームコラボレーション, クロスデバイス共有, 暗号化ファイル転送, 登録不要ファイル共有, 無制限ファイル転送, フォルダ同期, モバイルファイル転送, 安全なメッセージング, 即時ファイル共有, プライベートデータ転送",
|
||||
},
|
||||
about: {
|
||||
title: "SecureShareについて",
|
||||
description: "SecureShareについて学び、安全でプライベートなファイル転送とクリップボード共有サービスを提供する私たちの使命、およびユーザーのプライバシーとデータ保護への取り組みについてご紹介します。"
|
||||
description:
|
||||
"SecureShareについて学び、安全でプライベートなファイル転送とクリップボード共有サービスを提供する私たちの使命、およびユーザーのプライバシーとデータ保護への取り組みについてご紹介します。",
|
||||
},
|
||||
faq:{
|
||||
faq: {
|
||||
title: "SecureShare FAQ",
|
||||
description: "SecureShareに関するよくある質問の回答を見つけましょう。ファイルの送信方法、クリップボードコンテンツの共有方法、安全でプライベートなデータ転送を確保する方法などが含まれます。",
|
||||
keywords: 'SecureShare FAQ,よくある質問,安全なファイル共有FAQ,プライベートデータ共有ヘルプ,エンドツーエンド暗号化ファイル転送,安全なクリップボード共有サポート,SecureShareの使用方法,ファイル転送FAQ,プライバシー重視の共有質問,SecureShareトラブルシューティング',
|
||||
description:
|
||||
"SecureShareに関するよくある質問の回答を見つけましょう。ファイルの送信方法、クリップボードコンテンツの共有方法、安全でプライベートなデータ転送を確保する方法などが含まれます。",
|
||||
keywords:
|
||||
"SecureShare FAQ,よくある質問,安全なファイル共有FAQ,プライベートデータ共有ヘルプ,エンドツーエンド暗号化ファイル転送,安全なクリップボード共有サポート,SecureShareの使用方法,ファイル転送FAQ,プライバシー重視の共有質問,SecureShareトラブルシューティング",
|
||||
},
|
||||
help: {
|
||||
title: "SecureShareヘルプとサポート",
|
||||
description: "SecureShareサポートへの連絡方法に関する情報や、私たちのサービスについての詳細を提供するAbout、利用規約、プライバシーポリシーページへのリンクを見つけましょう。"
|
||||
description:
|
||||
"SecureShareサポートへの連絡方法に関する情報や、私たちのサービスについての詳細を提供するAbout、利用規約、プライバシーポリシーページへのリンクを見つけましょう。",
|
||||
},
|
||||
privacy: {
|
||||
title: "SecureShareプライバシーポリシー",
|
||||
description: "SecureShareがどのようにあなたのプライバシーとデータを保護するか、情報収集、データ保存とセキュリティ、第三者とのデータ共有を行わないという私たちの取り組みについて理解しましょう。"
|
||||
description:
|
||||
"SecureShareがどのようにあなたのプライバシーとデータを保護するか、情報収集、データ保存とセキュリティ、第三者とのデータ共有を行わないという私たちの取り組みについて理解しましょう。",
|
||||
},
|
||||
terms: {
|
||||
title: "SecureShare利用規約",
|
||||
description: "SecureShareの利用規約を確認しましょう。サービスの適切な使用、データプライバシーとセキュリティ、責任の制限に関する情報が含まれます。"
|
||||
description:
|
||||
"SecureShareの利用規約を確認しましょう。サービスの適切な使用、データプライバシーとセキュリティ、責任の制限に関する情報が含まれます。",
|
||||
},
|
||||
},
|
||||
text: {
|
||||
Header:{
|
||||
Home_dis:"ホーム",
|
||||
Header: {
|
||||
Home_dis: "ホーム",
|
||||
Blog_dis: "ブログ",
|
||||
About_dis:"について",
|
||||
Help_dis:"ヘルプ",
|
||||
FAQ_dis:"FAQ",
|
||||
Terms_dis:"利用規約",
|
||||
Privacy_dis:"プライバシー",
|
||||
About_dis: "について",
|
||||
Help_dis: "ヘルプ",
|
||||
FAQ_dis: "FAQ",
|
||||
Terms_dis: "利用規約",
|
||||
Privacy_dis: "プライバシー",
|
||||
},
|
||||
Footer:{
|
||||
CopyrightNotice:"SecureShare. All rights reserved.",
|
||||
Terms_dis:"利用規約",
|
||||
Privacy_dis:"プライバシーポリシー",
|
||||
SupportedLanguages:"対応言語"
|
||||
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:"私たちのプライバシー慣行について質問や懸念がある場合は、お気軽にお問い合わせください。",
|
||||
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:"私たちは、これらの利用規約を随時更新する権利を留保します。変更は、更新された規約をウェブサイトに掲載した時点で即座に有効になります。定期的に利用規約を確認し、変更を確認するのはあなたの責任です。",
|
||||
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の詳細については、以下のページをご確認ください:",
|
||||
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:"詳細や質問については、以下のページをご覧ください:"
|
||||
about: {
|
||||
h1: "SecureShareについて",
|
||||
P1: "SecureShareは、プライバシーと使いやすさを考慮して設計された無料で安全なファイル転送とクリップボード共有ツールです。私たちの使命は、制限なくデバイス間でファイルを転送し、コンテンツを共有するためのシンプルで強力なソリューションを提供することです。",
|
||||
P2: "SecureShareの核心は、セキュリティとプライバシーへの取り組みです。私たちはエンドツーエンド暗号化を使用して、転送プロセス中にあなたのデータが保護されることを保証し、ファイルやクリップボードコンテンツをサーバーに保存しません。これにより、あなたのデータはローカルに保たれ、あなたの管理下に置かれます。",
|
||||
P3: "SecureShareを使用すると、登録やログインなしでテキスト、画像、任意のサイズのファイルを簡単に共有できます。私たちのプラットフォームは、高速で効率的、環境に優しい設計で、シームレスでユーザーフレンドリーな体験を提供することに重点を置いています。",
|
||||
P4: "私たちは、ユーザーがデジタルライフをコントロールできるようにすることを信じており、SecureShareはそのビジョンへの貢献です。私たちのツールが、プライバシーやセキュリティを損なうことなく、友人、家族、同僚と安全に共有し、コラボレーションするのに役立つことを願っています。",
|
||||
P5: "詳細や質問については、以下のページをご覧ください:",
|
||||
},
|
||||
HowItWorks:{
|
||||
HowItWorks: {
|
||||
h2: "使い方",
|
||||
h2_P: "3つの簡単なステップでファイルやメッセージを即座に共有",
|
||||
btn_try: "今すぐ試す →",
|
||||
step1_title:"入力またはファイルを選択",
|
||||
step1_description:"メッセージを入力するか、ファイル/フォルダを選択エリアにドラッグ&ドロップ",
|
||||
step2_title:"ルームに参加",
|
||||
step2_description:"「ルームに参加」ボタンをクリックして共有セッションを作成",
|
||||
step3_title:"受信",
|
||||
step3_description:"受信ページでルームIDを入力し、「ルームに参加」をクリックして共有コンテンツを取得",
|
||||
step1_title: "入力またはファイルを選択",
|
||||
step1_description:
|
||||
"メッセージを入力するか、ファイル/フォルダを選択エリアにドラッグ&ドロップ",
|
||||
step2_title: "ルームに参加",
|
||||
step2_description:
|
||||
"「ルームに参加」ボタンをクリックして共有セッションを作成",
|
||||
step3_title: "受信",
|
||||
step3_description:
|
||||
"受信ページでルームIDを入力し、「ルームに参加」をクリックして共有コンテンツを取得",
|
||||
},
|
||||
SystemDiagram:{
|
||||
SystemDiagram: {
|
||||
h2: "システム図",
|
||||
h2_P: "SecureShare: あなたのデータ、あなたの管理。シンプルで高速、プライベート。",
|
||||
},
|
||||
KeyFeatures:{
|
||||
KeyFeatures: {
|
||||
h2: "主な特徴",
|
||||
h3_1: "直接かつ安全",
|
||||
h3_1_P: "あなたのファイルは、あなたのデバイスから受信者のデバイスに直接送信されます。エンドツーエンド暗号化により、データは意図した受信者のみが理解できる言語で話しているかのようです。共有をやめたい場合は、ブラウザタブを閉じるだけで、電話を切るかのように簡単です。あなたがコントロールします。",
|
||||
h3_1_P:
|
||||
"あなたのファイルは、あなたのデバイスから受信者のデバイスに直接送信されます。エンドツーエンド暗号化により、データは意図した受信者のみが理解できる言語で話しているかのようです。共有をやめたい場合は、ブラウザタブを閉じるだけで、電話を切るかのように簡単です。あなたがコントロールします。",
|
||||
h3_2: "チームシナジー",
|
||||
h3_2_P: "1人と共有するのと同じくらい簡単に、チーム全体と共有できます。デジタル円卓をホストするかのように、全員が同時にファイルを受け取ります。クリエイティブプロジェクトでのコラボレーションや重要なドキュメントの配布に最適です。全員が同じ部屋にいるかのように、共有ビジョンを同時に受け取ります。ブレインストーミングセッション、チームプレゼンテーション、または複数の人が同時に接続する必要がある場面に最適です。",
|
||||
h3_2_P:
|
||||
"1人と共有するのと同じくらい簡単に、チーム全体と共有できます。デジタル円卓をホストするかのように、全員が同時にファイルを受け取ります。クリエイティブプロジェクトでのコラボレーションや重要なドキュメントの配布に最適です。全員が同じ部屋にいるかのように、共有ビジョンを同時に受け取ります。ブレインストーミングセッション、チームプレゼンテーション、または複数の人が同時に接続する必要がある場面に最適です。",
|
||||
h3_3: "制限なし、スマートな処理",
|
||||
h3_3_P: "どんなに大きくても何でも運べる魔法のパイプラインを想像してください!ディスク容量さえあれば、どんなサイズのファイルでも送信できます。特に大きなファイルの場合は、デバイス上の保存場所を選択できます。コンピュータの速度を低下させない特別な配達サービスのように、ファイルは直接ディスクに保存され、デバイスの高速性と応答性を維持します。",
|
||||
h3_3_P:
|
||||
"どんなに大きくても何でも運べる魔法のパイプラインを想像してください!ディスク容量さえあれば、どんなサイズのファイルでも送信できます。特に大きなファイルの場合は、デバイス上の保存場所を選択できます。コンピュータの速度を低下させない特別な配達サービスのように、ファイルは直接ディスクに保存され、デバイスの高速性と応答性を維持します。",
|
||||
h3_4: "思考のように迅速",
|
||||
h3_4_P: "テキスト、画像、さらにはフォルダ全体を瞬時に共有できます。デジタルデータをテレポートさせるかのようです。写真アルバム全体やドキュメントが詰まったフォルダを送信する必要がありますか?問題ありません!単一のファイルを共有するのと同じくらい簡単です。",
|
||||
h3_4_P:
|
||||
"テキスト、画像、さらにはフォルダ全体を瞬時に共有できます。デジタルデータをテレポートさせるかのようです。写真アルバム全体やドキュメントが詰まったフォルダを送信する必要がありますか?問題ありません!単一のファイルを共有するのと同じくらい簡単です。",
|
||||
h3_5: "環境に優しくクリーン",
|
||||
h3_5_P: "私たちは、デジタル版の対面会話のようなものです。他の場所には何も保存されません。これは、最小限のリソースを使用し、非常に環境に優しいことを意味します。デジタル世界に足跡を残さず、すべての人にとってクリーンで環境に優しい状態を保ちます。",
|
||||
h3_5_P:
|
||||
"私たちは、デジタル版の対面会話のようなものです。他の場所には何も保存されません。これは、最小限のリソースを使用し、非常に環境に優しいことを意味します。デジタル世界に足跡を残さず、すべての人にとってクリーンで環境に優しい状態を保ちます。",
|
||||
},
|
||||
faqs:{
|
||||
FAQ_dis:"よくある質問",
|
||||
question_0: "データは本当にローカルに保存され、他のサーバーに転送されませんか?",
|
||||
answer_0: "はい、すべてのデータはローカルで処理されます。ホームページのYouTubeビデオを確認してください。接続が確立された後、インターネットが切断されてもローカルネットワーク内でファイルを転送できます。将来的には、コードをオープンソース化して誰でも確認できるようにする予定です。",
|
||||
faqs: {
|
||||
FAQ_dis: "よくある質問",
|
||||
question_0:
|
||||
"データは本当にローカルに保存され、他のサーバーに転送されませんか?",
|
||||
answer_0:
|
||||
"はい、すべてのデータはローカルで処理されます。ホームページのYouTubeビデオを確認してください。接続が確立された後、インターネットが切断されてもローカルネットワーク内でファイルを転送できます。将来的には、コードをオープンソース化して誰でも確認できるようにする予定です。",
|
||||
question_1: "フォルダを送受信するにはどうすればいいですか?",
|
||||
answer_1: "フォルダを送信するのは、ファイルを送信するのと同じくらい簡単です。フォルダをファイル選択エリアにドラッグするか、エリアをクリックして選択し、「送信開始」ボタンをクリックします。受信側では、ユーザーは直接ダウンロードするか、ダウンロード前に保存ディレクトリを選択できます。前者はメモリに保存され、後者は直接ディスクに保存されます。",
|
||||
answer_1:
|
||||
"フォルダを送信するのは、ファイルを送信するのと同じくらい簡単です。フォルダをファイル選択エリアにドラッグするか、エリアをクリックして選択し、「送信開始」ボタンをクリックします。受信側では、ユーザーは直接ダウンロードするか、ダウンロード前に保存ディレクトリを選択できます。前者はメモリに保存され、後者は直接ディスクに保存されます。",
|
||||
question_2: "ルームIDを変更できますか?",
|
||||
answer_2: "はい、ルームIDを任意の文字列に変更できます。",
|
||||
question_3: "コンテンツを継続的に共有できますか?",
|
||||
answer_3: "接続が維持されている限り、共有コンテンツが変更されるたびに手動で「送信開始」ボタンをクリックして更新できます。",
|
||||
answer_3:
|
||||
"接続が維持されている限り、共有コンテンツが変更されるたびに手動で「送信開始」ボタンをクリックして更新できます。",
|
||||
question_4: "複数の受信者と同時にファイルを共有できますか?",
|
||||
answer_4: "もちろんです!1人が受信するのと複数人が同時に受信するのに違いはありません。",
|
||||
answer_4:
|
||||
"もちろんです!1人が受信するのと複数人が同時に受信するのに違いはありません。",
|
||||
question_5: "SecureShareを使用する際にデータは安全ですか?",
|
||||
answer_5: "完全に安全です。あなたのデータは常にローカルに保たれ、暗号化されたエンドツーエンド接続を介してデバイス間で転送されます。すべての転送データは暗号化され、あなたと受信者のみがアクセスできます。",
|
||||
question_6: "SecureShareを使用するためにアカウントを作成する必要がありますか?",
|
||||
answer_6: "登録やログインは不要です。サイトを開いてすぐに使用できます。便利さとスピードを優先しています。",
|
||||
answer_5:
|
||||
"完全に安全です。あなたのデータは常にローカルに保たれ、暗号化されたエンドツーエンド接続を介してデバイス間で転送されます。すべての転送データは暗号化され、あなたと受信者のみがアクセスできます。",
|
||||
question_6:
|
||||
"SecureShareを使用するためにアカウントを作成する必要がありますか?",
|
||||
answer_6:
|
||||
"登録やログインは不要です。サイトを開いてすぐに使用できます。便利さとスピードを優先しています。",
|
||||
question_7: "ファイルサイズに制限はありますか?",
|
||||
answer_7: "ファイルサイズや速度に制限はありません。十分なディスク容量があれば、ダウンロード前に保存ディレクトリを設定することで、任意のサイズのファイルを転送できます。",
|
||||
answer_7:
|
||||
"ファイルサイズや速度に制限はありません。十分なディスク容量があれば、ダウンロード前に保存ディレクトリを設定することで、任意のサイズのファイルを転送できます。",
|
||||
question_8: "フォルダや複数のファイルを一度に共有できますか?",
|
||||
answer_8: "はい、複数のファイルやフォルダを共有するのは、単一のファイルを共有するのと同じくらい簡単です。転送にファイルを追加することもできます。「送信開始」をクリックして、受信者に更新します。",
|
||||
answer_8:
|
||||
"はい、複数のファイルやフォルダを共有するのは、単一のファイルを共有するのと同じくらい簡単です。転送にファイルを追加することもできます。「送信開始」をクリックして、受信者に更新します。",
|
||||
question_9: "気が変わった場合、共有を停止するにはどうすればいいですか?",
|
||||
answer_9: "共有を停止するのは、ブラウザタブやウィンドウを閉じるのと同じくらい簡単です。これを行うと、接続が終了し、それ以上のデータ転送は行われません。",
|
||||
answer_9:
|
||||
"共有を停止するのは、ブラウザタブやウィンドウを閉じるのと同じくらい簡単です。これを行うと、接続が終了し、それ以上のデータ転送は行われません。",
|
||||
question_10: "SecureShareを使用するとデバイスが遅くなりますか?",
|
||||
answer_10: "いいえ、SecureShareは軽量で効率的に設計されています。保存ディレクトリを設定すると、すべての受信データはメモリをバイパスして直接ディスクに書き込まれるため、デバイスのパフォーマンスが維持されます。",
|
||||
answer_10:
|
||||
"いいえ、SecureShareは軽量で効率的に設計されています。保存ディレクトリを設定すると、すべての受信データはメモリをバイパスして直接ディスクに書き込まれるため、デバイスのパフォーマンスが維持されます。",
|
||||
question_11: "オフラインでSecureShareを使用できますか?",
|
||||
answer_11: "はい、送信者と受信者が同じローカルネットワーク上にある場合、インターネットに接続している間に同じルームに参加し、その後インターネットから切断してもファイル共有は機能します。詳細については、ホームページのYouTubeビデオを参照してください。",
|
||||
answer_11:
|
||||
"はい、送信者と受信者が同じローカルネットワーク上にある場合、インターネットに接続している間に同じルームに参加し、その後インターネットから切断してもファイル共有は機能します。詳細については、ホームページのYouTubeビデオを参照してください。",
|
||||
question_12: "SecureShareはサーバーを使用しますか?",
|
||||
answer_12: "はい、軽量のサーバーが存在しますが、暗号化接続を確立するためのシグナリングにのみ使用されます。接続が確立されると、すべてのデータは暗号化接続を介してデバイス間で直接転送されます。",
|
||||
answer_12:
|
||||
"はい、軽量のサーバーが存在しますが、暗号化接続を確立するためのシグナリングにのみ使用されます。接続が確立されると、すべてのデータは暗号化接続を介してデバイス間で直接転送されます。",
|
||||
question_13: "ルームIDの有効期間はどのくらいですか?",
|
||||
answer_13: "ルームIDの初期有効期間は24時間です。受信者がルームに参加すると、その時点から24時間自動的に延長されます。",
|
||||
answer_13:
|
||||
"ルームIDの初期有効期間は24時間です。受信者がルームに参加すると、その時点から24時間自動的に延長されます。",
|
||||
},
|
||||
clipboard_btn:{
|
||||
Pasted_dis:"貼り付け済み",
|
||||
Copied_dis:"コピー済み",
|
||||
clipboard_btn: {
|
||||
Pasted_dis: "貼り付け済み",
|
||||
Copied_dis: "コピー済み",
|
||||
},
|
||||
fileUploadHandler:{
|
||||
NoFileChosen_tips:"ファイルが選択されていません",
|
||||
fileChosen_tips_template: "{fileNum} ファイルと {folderNum} フォルダが選択されました",
|
||||
Drag_tips:"ファイル/フォルダをここにドラッグ&ドロップするか、クリックして選択",
|
||||
chosenDiagTitle:"アップロードタイプを選択",
|
||||
chosenDiagDescription:"ファイルまたはフォルダをアップロードするか選択してください",
|
||||
SelectFile_dis:"ファイルを選択",
|
||||
SelectFolder_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:"ダウンロード",
|
||||
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:"保存場所を選択",
|
||||
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コードをダウンロード",
|
||||
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: 'ルームは利用できません。別のルームをお試しください',
|
||||
ClipboardApp: {
|
||||
fetchRoom_err: "ルームの取得に失敗しました。もう一度お試しください。",
|
||||
roomCheck: {
|
||||
empty_msg: "ルームIDは空にできません",
|
||||
available_msg: "ルームは利用可能です",
|
||||
notAvailable_msg: "ルームは利用できません。別のルームをお試しください",
|
||||
},
|
||||
channelOpen_msg:"データチャネルが開かれ、データを受信する準備ができました...",
|
||||
waitting_tips:"受信者が接続するのを待っています。転送が完了するまでこのページを開いたままにしてください。デスクトップでは、ブラウザを最小化したり、タブを切り替えたりできます。モバイルでは、ブラウザをフォアグラウンドに保ってください。",
|
||||
channelOpen_msg:
|
||||
"データチャネルが開かれ、データを受信する準備ができました...",
|
||||
waitting_tips:
|
||||
"受信者が接続するのを待っています。転送が完了するまでこのページを開いたままにしてください。デスクトップでは、ブラウザを最小化したり、タブを切り替えたりできます。モバイルでは、ブラウザをフォアグラウンドに保ってください。",
|
||||
joinRoom: {
|
||||
EmptyMsg: "警告、ルームIDが空です",
|
||||
DuplicateMsg: "入力したルームIDが重複しています。再入力してください。",
|
||||
successMsg: "ルームに成功して参加しました!転送が完了するまでこのページを閉じないでください。(PCではブラウザを最小化したりタブを切り替えたりできます。モバイルではブラウザをバックグラウンドにしないでください。)",
|
||||
notExist: "参加しようとしているルームは存在しません。送信者のみがルームを作成できます。",
|
||||
failMsg: "ルームへの参加に失敗しました:"
|
||||
successMsg:
|
||||
"ルームに成功して参加しました!転送が完了するまでこのページを閉じないでください。(PCではブラウザを最小化したりタブを切り替えたりできます。モバイルではブラウザをバックグラウンドにしないでください。)",
|
||||
notExist:
|
||||
"参加しようとしているルームは存在しません。送信者のみがルームを作成できます。",
|
||||
failMsg: "ルームへの参加に失敗しました:",
|
||||
},
|
||||
pickSaveMsg: "ディスクに直接保存しますか?",
|
||||
roomStatus: {
|
||||
@@ -222,40 +272,41 @@ export const ja: Messages = {
|
||||
receiverEmptyMsg: "招待を受けてルームに参加できます",
|
||||
onlyOneMsg: "あなただけがここにいます",
|
||||
peopleMsg_template: "{peerCount} 人がルームにいます",
|
||||
connected_dis:"接続済み",
|
||||
connected_dis: "接続済み",
|
||||
},
|
||||
html:{
|
||||
html: {
|
||||
senderTab: "送信",
|
||||
retrieveTab: "取得",
|
||||
shareTitle_dis:"共有コンテンツ",
|
||||
retrieveTitle_dis:"取得コンテンツ",
|
||||
RoomStatus_dis:"ステータス:",
|
||||
Paste_dis:"貼り付け",
|
||||
Copy_dis:"コピー",
|
||||
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:"取得方法",
|
||||
}
|
||||
inputRoomId_tips: "ルームID(編集可能):",
|
||||
joinRoom_dis: "ルームに参加",
|
||||
startSending_loadingText: "送信済み",
|
||||
startSending_dis: "送信開始",
|
||||
readClipboard_dis: "ルームIDを貼り付け",
|
||||
retrieveRoomId_placeholder: "ルームIDを入力",
|
||||
RetrieveMethodTitle: "取得方法",
|
||||
},
|
||||
},
|
||||
home: {
|
||||
h1: "無料で安全なオンラインクリップボード&ファイル転送ツール",
|
||||
h1P: "テキスト、画像、ファイル、フォルダをプライバシーを守りながら簡単に共有できます。登録不要で完全無料。サイズや速度に制限はありません。エンドツーエンド暗号化された転送をデバイス間で直接行い、コストゼロで利用できます。",
|
||||
h2_screenOnly: '今すぐ安全なクリップボード&ファイル転送ツールを試す',
|
||||
h2_screenOnly: "今すぐ安全なクリップボード&ファイル転送ツールを試す",
|
||||
h2_demo: "安全なファイル共有のデモを見る",
|
||||
h2P_demo: "ローカルファースト、エンドツーエンド暗号化されたファイル共有がどのようにプライバシーを保護するかを見てください",
|
||||
watch_tips:"これらのプラットフォームでもビデオを視聴できます:",
|
||||
h2P_demo:
|
||||
"ローカルファースト、エンドツーエンド暗号化されたファイル共有がどのようにプライバシーを保護するかを見てください",
|
||||
watch_tips: "これらのプラットフォームでもビデオを視聴できます:",
|
||||
youtube_tips: "YouTubeでSecureShareを見る",
|
||||
bilibili_tips:"BilibiliでSecureShareを見る",
|
||||
}
|
||||
bilibili_tips: "BilibiliでSecureShareを見る",
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
+205
-169
@@ -1,157 +1,190 @@
|
||||
import { Messages } from '@/types/messages'
|
||||
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使用条款,包括服务使用规范、数据隐私和安全性,以及责任限制等信息。"
|
||||
},
|
||||
meta: {
|
||||
home: {
|
||||
title: "SecureShare:免费P2P文件传输与剪贴板共享 | 隐私无上传",
|
||||
description:
|
||||
"SecureShare提供即时安全的P2P文件传输,无大小限制,无需注册。支持文本、图片、文件夹跨设备分享,端到端加密。完美支持团队协作和私密文件传输。",
|
||||
keywords:
|
||||
"文件共享,安全文件传输,P2P文件传输,webrtc文件共享,私密剪贴板,团队协作,跨设备共享,加密文件传输,免注册文件共享,无限制文件传输,文件夹同步,手机文件传输,安全通讯,即时文件共享,私密数据传输",
|
||||
},
|
||||
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小时。",
|
||||
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: "已粘贴",
|
||||
@@ -176,15 +209,16 @@ export const zh: Messages = {
|
||||
Download_dis: "下载",
|
||||
},
|
||||
FileListDisplay: {
|
||||
sending_dis: '发送中',
|
||||
receiving_dis: '接收中',
|
||||
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: "我们建议选择一个保存目录来直接将文件保存到磁盘。这样可以更方便地传输大文件和同步文件夹。",
|
||||
PopupDialog_description:
|
||||
"我们建议选择一个保存目录来直接将文件保存到磁盘。这样可以更方便地传输大文件和同步文件夹。",
|
||||
chooseSavePath_tips: "大文件或文件夹可直接保存到指定目录 👉",
|
||||
chooseSavePath_dis: "选择保存位置",
|
||||
},
|
||||
@@ -203,17 +237,19 @@ export const zh: Messages = {
|
||||
fetchRoom_err: "获取房间失败,请重试。",
|
||||
roomCheck: {
|
||||
empty_msg: "房间ID不能为空",
|
||||
available_msg: '房间可用',
|
||||
notAvailable_msg: '房间不可用,请尝试其他房间',
|
||||
available_msg: "房间可用",
|
||||
notAvailable_msg: "房间不可用,请尝试其他房间",
|
||||
},
|
||||
channelOpen_msg: "数据通道已开启,准备接收数据...",
|
||||
waitting_tips: "等待接收方连接。请保持此页面打开直到传输完成。在桌面端,您可以最小化浏览器或切换标签页。在移动端,请保持浏览器在前台。",
|
||||
waitting_tips:
|
||||
"等待接收方连接。请保持此页面打开直到传输完成。在桌面端,您可以最小化浏览器或切换标签页。在移动端,请保持浏览器在前台。",
|
||||
joinRoom: {
|
||||
EmptyMsg: "警告,房间ID为空",
|
||||
DuplicateMsg: "您输入的房间ID重复,请重新输入。",
|
||||
successMsg: "成功加入房间!在被接收之前不要关闭当前页(电脑端可以最小化浏览器或切换tab页,移动端不要将浏览器切到后台)。",
|
||||
successMsg:
|
||||
"成功加入房间!在被接收之前不要关闭当前页(电脑端可以最小化浏览器或切换tab页,移动端不要将浏览器切到后台)。",
|
||||
notExist: "您尝试加入的房间不存在。只有发送方可以创建房间。",
|
||||
failMsg: "加入房间失败:"
|
||||
failMsg: "加入房间失败:",
|
||||
},
|
||||
pickSaveMsg: "直接保存到磁盘?",
|
||||
roomStatus: {
|
||||
@@ -221,13 +257,13 @@ export const zh: Messages = {
|
||||
receiverEmptyMsg: "您可以接受邀请加入房间",
|
||||
onlyOneMsg: "只有您一人在房间内",
|
||||
peopleMsg_template: `房间内共{peerCount}人`,
|
||||
connected_dis:"已连接",
|
||||
connected_dis: "已连接",
|
||||
},
|
||||
html: {
|
||||
senderTab: "发送",
|
||||
retrieveTab: "接收",
|
||||
shareTitle_dis:"分享内容",
|
||||
retrieveTitle_dis:"接收内容",
|
||||
shareTitle_dis: "分享内容",
|
||||
retrieveTitle_dis: "接收内容",
|
||||
RoomStatus_dis: "状态:",
|
||||
Paste_dis: "粘贴",
|
||||
Copy_dis: "复制",
|
||||
@@ -244,17 +280,17 @@ export const zh: Messages = {
|
||||
readClipboard_dis: "粘贴房间ID",
|
||||
retrieveRoomId_placeholder: "输入房间ID",
|
||||
RetrieveMethodTitle: "接收方式",
|
||||
}
|
||||
},
|
||||
},
|
||||
home: {
|
||||
h1: "免费安全的在线剪贴板与文件传输工具",
|
||||
h1P: "轻松共享文本、图片、文件和文件夹,享受无与伦比的隐私保护,完全免费且无需注册。无大小和速度限制。设备间直接传输,端到端加密,零成本。",
|
||||
h2_screenOnly: '立即体验安全剪贴板与文件传输工具',
|
||||
h2_screenOnly: "立即体验安全剪贴板与文件传输工具",
|
||||
h2_demo: "观看安全文件共享演示",
|
||||
h2P_demo: "了解我们如何通过本地优先、端到端加密的文件共享保护您的隐私",
|
||||
watch_tips:"也可以在以下平台观看视频:",
|
||||
watch_tips: "也可以在以下平台观看视频:",
|
||||
youtube_tips: "在 YouTube 观看 SecureShare",
|
||||
bilibili_tips:"在 Bilibili 观看 SecureShare",
|
||||
bilibili_tips: "在 Bilibili 观看 SecureShare",
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useLocale } from '@/hooks/useLocale';
|
||||
import { getDictionary } from '@/lib/dictionary';
|
||||
import type { Messages } from '@/types/messages';
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useLocale } from "@/hooks/useLocale";
|
||||
import { getDictionary } from "@/lib/dictionary";
|
||||
import type { Messages } from "@/types/messages";
|
||||
|
||||
interface ClipboardMessages {
|
||||
copiedSuccess?: string;
|
||||
@@ -29,90 +29,101 @@ export const useClipboardActions = (): ClipboardActions => {
|
||||
const [isPasted, setIsPasted] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [clipboardMessages, setClipboardMessages] = useState<ClipboardMessages>({});
|
||||
const [clipboardMessages, setClipboardMessages] = useState<ClipboardMessages>(
|
||||
{}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoadingMessages(true);
|
||||
getDictionary(locale)
|
||||
.then(dict => {
|
||||
.then((dict) => {
|
||||
setMessages(dict);
|
||||
setClipboardMessages({
|
||||
copiedSuccess: dict.text.clipboard_btn.Copied_dis,
|
||||
pastedSuccess: dict.text.clipboard_btn.Pasted_dis,
|
||||
copyError: dict.text.clipboard_btn.Copy_failed_dis || 'Failed to copy.', // Fallback
|
||||
readError: dict.text.clipboard_btn.Paste_failed_dis || 'Failed to read clipboard.', // Fallback
|
||||
loading: dict.text.Loading_dis || 'Loading...', // Fallback
|
||||
copyError:
|
||||
dict.text.clipboard_btn.Copy_failed_dis || "Failed to copy.", // Fallback
|
||||
readError:
|
||||
dict.text.clipboard_btn.Paste_failed_dis ||
|
||||
"Failed to read clipboard.", // Fallback
|
||||
loading: dict.text.Loading_dis || "Loading...", // Fallback
|
||||
});
|
||||
setIsLoadingMessages(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to load messages for useClipboardActions:', err);
|
||||
setError('Failed to load messages');
|
||||
setClipboardMessages({ // Provide fallbacks even on error
|
||||
copyError: 'Failed to copy.',
|
||||
readError: 'Failed to read clipboard.',
|
||||
loading: 'Loading...',
|
||||
.catch((err) => {
|
||||
console.error("Failed to load messages for useClipboardActions:", err);
|
||||
setError("Failed to load messages");
|
||||
setClipboardMessages({
|
||||
// Provide fallbacks even on error
|
||||
copyError: "Failed to copy.",
|
||||
readError: "Failed to read clipboard.",
|
||||
loading: "Loading...",
|
||||
});
|
||||
setIsLoadingMessages(false);
|
||||
});
|
||||
}, [locale]);
|
||||
|
||||
const copyText = useCallback(async (textToCopy: string) => {
|
||||
setError(null);
|
||||
setIsCopied(false);
|
||||
if (!navigator.clipboard) {
|
||||
setError(clipboardMessages.copyError || 'Clipboard API not available.');
|
||||
const copyText = useCallback(
|
||||
async (textToCopy: string) => {
|
||||
setError(null);
|
||||
setIsCopied(false);
|
||||
if (!navigator.clipboard) {
|
||||
setError(clipboardMessages.copyError || "Clipboard API not available.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(textToCopy);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text: ', err);
|
||||
setError(clipboardMessages.copyError || 'Failed to copy.');
|
||||
}
|
||||
}, [clipboardMessages.copyError]);
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(textToCopy);
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy text: ", err);
|
||||
setError(clipboardMessages.copyError || "Failed to copy.");
|
||||
}
|
||||
},
|
||||
[clipboardMessages.copyError]
|
||||
);
|
||||
|
||||
const readClipboard = useCallback(async (): Promise<string | null> => {
|
||||
setError(null);
|
||||
setIsPasted(false);
|
||||
if (!navigator.clipboard) {
|
||||
setError(clipboardMessages.readError || 'Clipboard API not available.');
|
||||
return null;
|
||||
setError(clipboardMessages.readError || "Clipboard API not available.");
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const clipboardItems = await navigator.clipboard.read();
|
||||
for (const clipboardItem of clipboardItems) {
|
||||
if (clipboardItem.types.includes('text/html')) {
|
||||
const blob = await clipboardItem.getType('text/html');
|
||||
if (clipboardItem.types.includes("text/html")) {
|
||||
const blob = await clipboardItem.getType("text/html");
|
||||
const html = await blob.text();
|
||||
setIsPasted(true);
|
||||
setTimeout(() => setIsPasted(false), 2000);
|
||||
return html;
|
||||
}
|
||||
if (clipboardItem.types.includes('text/plain')) {
|
||||
const blob = await clipboardItem.getType('text/plain');
|
||||
if (clipboardItem.types.includes("text/plain")) {
|
||||
const blob = await clipboardItem.getType("text/plain");
|
||||
const text = await blob.text();
|
||||
const formattedText = text.replace(/\n/g, '<br>');
|
||||
const formattedText = text.replace(/\n/g, "<br>");
|
||||
setIsPasted(true);
|
||||
setTimeout(() => setIsPasted(false), 2000);
|
||||
return formattedText;
|
||||
}
|
||||
}
|
||||
console.warn('No suitable content type found in clipboard.');
|
||||
setError(clipboardMessages.readError || 'No suitable content type found.');
|
||||
console.warn("No suitable content type found in clipboard.");
|
||||
setError(
|
||||
clipboardMessages.readError || "No suitable content type found."
|
||||
);
|
||||
return null;
|
||||
} catch (err) {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
const formattedText = text.replace(/\n/g, '<br>');
|
||||
const formattedText = text.replace(/\n/g, "<br>");
|
||||
setIsPasted(true);
|
||||
setTimeout(() => setIsPasted(false), 2000);
|
||||
return formattedText;
|
||||
} catch (fallbackErr) {
|
||||
console.error('Failed to read clipboard: ', fallbackErr);
|
||||
setError(clipboardMessages.readError || 'Failed to read clipboard.');
|
||||
console.error("Failed to read clipboard: ", fallbackErr);
|
||||
setError(clipboardMessages.readError || "Failed to read clipboard.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -127,4 +138,4 @@ export const useClipboardActions = (): ClipboardActions => {
|
||||
error,
|
||||
clipboardMessages,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
import { useState } from 'react';
|
||||
import { useState } from "react";
|
||||
|
||||
export interface AppMessages {
|
||||
shareMessage: string;
|
||||
retrieveMessage: string;
|
||||
putMessageInMs: (message: string, isShareEnd?: boolean, displayTimeMs?: number) => void;
|
||||
putMessageInMs: (
|
||||
message: string,
|
||||
isShareEnd?: boolean,
|
||||
displayTimeMs?: number
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function useClipboardAppMessages(): AppMessages {
|
||||
const [shareMessage, setShareMessage] = useState('');
|
||||
const [retrieveMessage, setRetrieveMessage] = useState('');
|
||||
const [shareMessage, setShareMessage] = useState("");
|
||||
const [retrieveMessage, setRetrieveMessage] = useState("");
|
||||
|
||||
const putMessageInMs = (message: string, isShareEnd = true, displayTimeMs = 4000) => {
|
||||
const putMessageInMs = (
|
||||
message: string,
|
||||
isShareEnd = true,
|
||||
displayTimeMs = 4000
|
||||
) => {
|
||||
if (isShareEnd) {
|
||||
setShareMessage(message);
|
||||
setTimeout(() => setShareMessage(''), displayTimeMs);
|
||||
setTimeout(() => setShareMessage(""), displayTimeMs);
|
||||
} else {
|
||||
setRetrieveMessage(message);
|
||||
setTimeout(() => setRetrieveMessage(''), displayTimeMs);
|
||||
setTimeout(() => setRetrieveMessage(""), displayTimeMs);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -25,4 +33,4 @@ export function useClipboardAppMessages(): AppMessages {
|
||||
retrieveMessage,
|
||||
putMessageInMs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
'use client';
|
||||
"use client";
|
||||
// Get the current language
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { i18n } from '@/constants/i18n-config'
|
||||
import { usePathname } from "next/navigation";
|
||||
import { i18n } from "@/constants/i18n-config";
|
||||
|
||||
export function useLocale() {
|
||||
const pathname = usePathname();
|
||||
const locale = pathname?.split('/')[1];
|
||||
|
||||
const locale = pathname?.split("/")[1];
|
||||
|
||||
// Validate if the language is supported
|
||||
if (locale && i18n.locales.includes(locale as any)) {
|
||||
return locale;
|
||||
}
|
||||
|
||||
|
||||
return i18n.defaultLocale;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getDictionary } from '@/lib/dictionary';
|
||||
import { useLocale } from '@/hooks/useLocale';
|
||||
import { trackReferrer } from '@/lib/tracking';
|
||||
import type { Messages } from '@/types/messages';
|
||||
import { useState, useEffect } from "react";
|
||||
import { getDictionary } from "@/lib/dictionary";
|
||||
import { useLocale } from "@/hooks/useLocale";
|
||||
import { trackReferrer } from "@/lib/tracking";
|
||||
import type { Messages } from "@/types/messages";
|
||||
|
||||
interface UsePageSetupProps {
|
||||
setRetrieveRoomId: (roomId: string) => void;
|
||||
setActiveTab: (tab: 'send' | 'retrieve') => void;
|
||||
setActiveTab: (tab: "send" | "retrieve") => void;
|
||||
retrieveJoinRoomBtnRef: React.RefObject<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
@@ -23,11 +23,11 @@ export function usePageSetup({
|
||||
useEffect(() => {
|
||||
setIsLoadingMessages(true);
|
||||
getDictionary(locale)
|
||||
.then(dict => {
|
||||
.then((dict) => {
|
||||
setMessages(dict);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to load messages:', error);
|
||||
.catch((error) => {
|
||||
console.error("Failed to load messages:", error);
|
||||
// Optionally set some default/fallback messages or an error state
|
||||
setMessages(null); // Or some error indicator
|
||||
})
|
||||
@@ -41,18 +41,18 @@ export function usePageSetup({
|
||||
trackReferrer(); // Call on component mount
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const roomIdParam = urlParams.get('roomId');
|
||||
const roomIdParam = urlParams.get("roomId");
|
||||
|
||||
if (roomIdParam) {
|
||||
setRetrieveRoomId(roomIdParam);
|
||||
setActiveTab('retrieve');
|
||||
setActiveTab("retrieve");
|
||||
// Ensure DOM is updated and ref is available before clicking
|
||||
const timeoutId = setTimeout(() => {
|
||||
retrieveJoinRoomBtnRef.current?.click();
|
||||
}, 200);
|
||||
}, 200);
|
||||
return () => clearTimeout(timeoutId); // Cleanup timeout
|
||||
}
|
||||
}, [setRetrieveRoomId, setActiveTab, retrieveJoinRoomBtnRef]); // Dependencies are stable setters and a ref
|
||||
|
||||
return { messages, isLoadingMessages };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// We convert the function into a custom Hook useRichTextToPlainText.
|
||||
// We convert the function into a custom Hook useRichTextToPlainText.
|
||||
// This allows us to use React's lifecycle methods to detect if we are in a browser environment.
|
||||
// Use useState and useEffect to detect if we are in a browser environment.
|
||||
// Use useState and useEffect to detect if we are in a browser environment.
|
||||
// useEffect only runs on the client side, so we can safely set isBrowser to true in it.
|
||||
function useRichTextToPlainText() {
|
||||
const [isBrowser, setIsBrowser] = useState(false);
|
||||
@@ -17,17 +17,17 @@ function useRichTextToPlainText() {
|
||||
}
|
||||
// Create a temporary DOM element
|
||||
const tempElement = document.createElement("div");
|
||||
|
||||
|
||||
// Set the rich text content as the innerHTML of the temporary element
|
||||
tempElement.innerHTML = richText;
|
||||
|
||||
|
||||
// Process direct text nodes (text not inside any block-level elements)
|
||||
// Wrap them in a div for consistent processing
|
||||
const wrapTextNodes = (element: HTMLElement) => {
|
||||
const childNodes = Array.from(element.childNodes);
|
||||
childNodes.forEach(node => {
|
||||
childNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE && node.textContent?.trim()) {
|
||||
const wrapper = document.createElement('div');
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.textContent = node.textContent;
|
||||
node.replaceWith(wrapper);
|
||||
}
|
||||
@@ -37,33 +37,43 @@ function useRichTextToPlainText() {
|
||||
wrapTextNodes(tempElement);
|
||||
|
||||
// Process all block-level elements
|
||||
const blockElements = ['div', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre'];
|
||||
blockElements.forEach(tag => {
|
||||
tempElement.querySelectorAll(tag).forEach(element => {
|
||||
const blockElements = [
|
||||
"div",
|
||||
"p",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"pre",
|
||||
];
|
||||
blockElements.forEach((tag) => {
|
||||
tempElement.querySelectorAll(tag).forEach((element) => {
|
||||
// If the element content is empty or only contains <br>, replace it with a double newline
|
||||
if (!element.textContent?.trim() || element.innerHTML === '<br>') {
|
||||
element.replaceWith('\n\n');
|
||||
if (!element.textContent?.trim() || element.innerHTML === "<br>") {
|
||||
element.replaceWith("\n\n");
|
||||
} else {
|
||||
// Otherwise, add a newline after the content
|
||||
element.replaceWith(element.textContent + '\n');
|
||||
element.replaceWith(element.textContent + "\n");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Process <br> tags
|
||||
tempElement.querySelectorAll('br').forEach(br => {
|
||||
br.replaceWith('\n');
|
||||
tempElement.querySelectorAll("br").forEach((br) => {
|
||||
br.replaceWith("\n");
|
||||
});
|
||||
|
||||
// Get and process the plain text
|
||||
let plainText = tempElement.textContent || tempElement.innerText || '';
|
||||
let plainText = tempElement.textContent || tempElement.innerText || "";
|
||||
|
||||
// Process consecutive newline characters
|
||||
plainText = plainText
|
||||
.replace(/\n{3,}/g, '\n\n') // Replace 3 or more consecutive newline characters with 2
|
||||
.replace(/^\n+/, '') // Remove leading newline characters
|
||||
.replace(/\n+$/, '') // Remove trailing newline characters
|
||||
.trim(); // Trim leading/trailing whitespace
|
||||
.replace(/\n{3,}/g, "\n\n") // Replace 3 or more consecutive newline characters with 2
|
||||
.replace(/^\n+/, "") // Remove leading newline characters
|
||||
.replace(/\n+$/, "") // Remove trailing newline characters
|
||||
.trim(); // Trim leading/trailing whitespace
|
||||
|
||||
return plainText;
|
||||
};
|
||||
|
||||
+70
-65
@@ -1,99 +1,104 @@
|
||||
// Blog utility functions
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import matter from 'gray-matter'
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import matter from "gray-matter";
|
||||
|
||||
const POSTS_PATH = path.join(process.cwd(), 'content/blog')
|
||||
const POSTS_PATH = path.join(process.cwd(), "content/blog");
|
||||
|
||||
export interface BlogPost {
|
||||
slug: string
|
||||
slug: string;
|
||||
frontmatter: {
|
||||
title: string
|
||||
description: string
|
||||
date: string
|
||||
author: string
|
||||
cover: string
|
||||
tags: string[] // Use the tags array directly
|
||||
status: string
|
||||
}
|
||||
content: string
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
author: string;
|
||||
cover: string;
|
||||
tags: string[]; // Use the tags array directly
|
||||
status: string;
|
||||
};
|
||||
content: string;
|
||||
}
|
||||
|
||||
export async function getAllPosts(lang: string): Promise<BlogPost[]> {
|
||||
const files = fs.readdirSync(POSTS_PATH)
|
||||
|
||||
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)
|
||||
|
||||
const filePath = path.join(POSTS_PATH, file);
|
||||
const source = fs.readFileSync(filePath, "utf8");
|
||||
const { data, content } = matter(source);
|
||||
|
||||
// Validate and transform frontmatter data
|
||||
const frontmatter = {
|
||||
title: data.title ?? '',
|
||||
description: data.description ?? '',
|
||||
title: data.title ?? "",
|
||||
description: data.description ?? "",
|
||||
date: data.date ?? new Date().toISOString(),
|
||||
author: data.author ?? '',
|
||||
cover: data.cover ?? '',
|
||||
author: data.author ?? "",
|
||||
cover: data.cover ?? "",
|
||||
tags: Array.isArray(data.tags) ? data.tags : [], // Use the tags array directly
|
||||
status: data.status ?? 'draft'
|
||||
}
|
||||
|
||||
status: data.status ?? "draft",
|
||||
};
|
||||
|
||||
return {
|
||||
slug: file.replace(/\.mdx?$/, ''),
|
||||
slug: file.replace(/\.mdx?$/, ""),
|
||||
frontmatter,
|
||||
content
|
||||
} as BlogPost
|
||||
content,
|
||||
} as BlogPost;
|
||||
})
|
||||
)
|
||||
|
||||
// Filter out draft status blogs
|
||||
return posts
|
||||
.filter(post => post.frontmatter.status === 'published') // Only keep published status
|
||||
.filter(post => {
|
||||
// Split slug into an array by '-'
|
||||
const parts = post.slug.split('-');
|
||||
// Get the last part
|
||||
const lastPart = parts[parts.length - 1];
|
||||
// Check if the last part equals the target language && if the target language is Chinese, return Chinese blogs, otherwise return English blogs
|
||||
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()
|
||||
)
|
||||
);
|
||||
|
||||
// Filter out draft status blogs
|
||||
return posts
|
||||
.filter((post) => post.frontmatter.status === "published") // Only keep published status
|
||||
.filter((post) => {
|
||||
// Split slug into an array by '-'
|
||||
const parts = post.slug.split("-");
|
||||
// Get the last part
|
||||
const lastPart = parts[parts.length - 1];
|
||||
// Check if the last part equals the target language && if the target language is Chinese, return Chinese blogs, otherwise return English blogs
|
||||
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)
|
||||
|
||||
const filePath = path.join(POSTS_PATH, `${slug}.mdx`);
|
||||
const source = fs.readFileSync(filePath, "utf8");
|
||||
const { data, content } = matter(source);
|
||||
|
||||
// Validate and transform frontmatter data
|
||||
const frontmatter = {
|
||||
title: data.title ?? '',
|
||||
description: data.description ?? '',
|
||||
title: data.title ?? "",
|
||||
description: data.description ?? "",
|
||||
date: data.date ?? new Date().toISOString(),
|
||||
author: data.author ?? '',
|
||||
cover: data.cover ?? '',
|
||||
author: data.author ?? "",
|
||||
cover: data.cover ?? "",
|
||||
tags: Array.isArray(data.tags) ? data.tags : [],
|
||||
status: data.status ?? 'draft'
|
||||
}
|
||||
|
||||
status: data.status ?? "draft",
|
||||
};
|
||||
|
||||
return {
|
||||
slug,
|
||||
frontmatter,
|
||||
content
|
||||
}
|
||||
content,
|
||||
};
|
||||
} catch (error) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Get blog posts by tag
|
||||
export async function getPostsByTag(tag: string,lang: string): Promise<BlogPost[]> {
|
||||
const allPosts = await getAllPosts(lang)
|
||||
return allPosts.filter(post => post.frontmatter.tags.includes(tag))
|
||||
}
|
||||
export async function getPostsByTag(
|
||||
tag: string,
|
||||
lang: string
|
||||
): Promise<BlogPost[]> {
|
||||
const allPosts = await getAllPosts(lang);
|
||||
return allPosts.filter((post) => post.frontmatter.tags.includes(tag));
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
// Language dictionary loader
|
||||
import { supportedLocales, i18n } from '@/constants/i18n-config';
|
||||
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.`);
|
||||
console.warn(
|
||||
`Unsupported locale: ${locale}, falling back to default locale.`
|
||||
);
|
||||
locale = i18n.defaultLocale;
|
||||
}
|
||||
const messagesModule = await import(`@/constants/messages/${locale}`);
|
||||
const messagesModule = await import(`@/constants/messages/${locale}`);
|
||||
const messages = messagesModule[locale]; // Get the exported object based on the language code
|
||||
return messages;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load dictionary for locale: ${locale}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { CustomFile } from '@/types/webrtc';
|
||||
import { CustomFile } from "@/types/webrtc";
|
||||
|
||||
// Adaptively format the file size with units
|
||||
export const formatFileSize = (sizeInBytes: number): string => {
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = sizeInBytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
@@ -22,13 +22,16 @@ export const generateFileId = (file: CustomFile): string => {
|
||||
* @param file - The file blob or any file-like object that can be used with URL.createObjectURL.
|
||||
* @param saveName - The name to use for the downloaded file.
|
||||
*/
|
||||
export const downloadAs = async (file: Blob | File, saveName: string): Promise<void> => {
|
||||
export const downloadAs = async (
|
||||
file: Blob | File,
|
||||
saveName: string
|
||||
): Promise<void> => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const a = document.createElement('a');
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = saveName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
};
|
||||
|
||||
+54
-43
@@ -1,36 +1,37 @@
|
||||
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';
|
||||
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 Node Type Definition
|
||||
interface MdxJsxFlowElement {
|
||||
type: 'mdxJsxFlowElement';
|
||||
type: "mdxJsxFlowElement";
|
||||
name: string;
|
||||
children: Text[];
|
||||
}
|
||||
|
||||
// Extended Properties Type
|
||||
interface ExtendedProperties extends Properties {
|
||||
className?: string;
|
||||
id?: string;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
// Extended Element Type
|
||||
interface ExtendedElement extends Omit<Element, 'properties'> {
|
||||
interface ExtendedElement extends Omit<Element, "properties"> {
|
||||
properties: ExtendedProperties;
|
||||
}
|
||||
|
||||
// Generate a valid ID, preserving Chinese characters
|
||||
const generateValidId = (text: string): string => {
|
||||
return encodeURIComponent(text
|
||||
.trim() // Trim leading/trailing whitespace
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/\-\-+/g, '-') // Replace multiple hyphens with a single one
|
||||
.replace(/^-+/, '') // Remove leading hyphens
|
||||
.replace(/-+$/, '') // Remove trailing hyphens
|
||||
return encodeURIComponent(
|
||||
text
|
||||
.trim() // Trim leading/trailing whitespace
|
||||
.replace(/\s+/g, "-") // Replace spaces with hyphens
|
||||
.replace(/\-\-+/g, "-") // Replace multiple hyphens with a single one
|
||||
.replace(/^-+/, "") // Remove leading hyphens
|
||||
.replace(/-+$/, "") // Remove trailing hyphens
|
||||
);
|
||||
};
|
||||
|
||||
@@ -52,13 +53,15 @@ export const mdxOptions = {
|
||||
// Mermaid code block processing plugin
|
||||
(() => {
|
||||
return (tree: MdastRoot) => {
|
||||
visit(tree, 'code', (node: Code) => {
|
||||
if (node.lang === 'mermaid') {
|
||||
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];
|
||||
}
|
||||
mermaidNode.type = "mdxJsxFlowElement";
|
||||
mermaidNode.name = "mermaid";
|
||||
mermaidNode.children = [
|
||||
{ type: "text", value: node.value } as Text,
|
||||
];
|
||||
}
|
||||
});
|
||||
return tree;
|
||||
};
|
||||
@@ -68,53 +71,61 @@ export const mdxOptions = {
|
||||
// Plugin to handle images and tables
|
||||
(() => {
|
||||
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';
|
||||
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'
|
||||
className: "image-container",
|
||||
};
|
||||
}
|
||||
}
|
||||
if (node.tagName === 'table') {
|
||||
if (node.tagName === "table") {
|
||||
(node as ExtendedElement).properties = {
|
||||
...((node as ExtendedElement).properties || {}),
|
||||
className: 'min-w-full divide-y divide-gray-300'
|
||||
className: "min-w-full divide-y divide-gray-300",
|
||||
};
|
||||
}
|
||||
}) as BuildVisitor<Root, 'element'>);
|
||||
}) as BuildVisitor<Root, "element">);
|
||||
return tree;
|
||||
};
|
||||
}) as Plugin<[], Root>,
|
||||
|
||||
|
||||
// Plugin to handle heading IDs
|
||||
(() => {
|
||||
return (tree: Root) => {
|
||||
const usedIds = new Set<string>();// Keep track of used IDs to avoid duplicates
|
||||
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) => {
|
||||
const usedIds = new Set<string>(); // Keep track of used IDs to avoid duplicates
|
||||
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'>);
|
||||
|
||||
}) as BuildVisitor<Element, "text">);
|
||||
|
||||
if (titleText) {
|
||||
let id = generateValidId(titleText);
|
||||
let uniqueId = getUniqueId(id, usedIds);// Handle duplicate IDs by adding a numeric suffix
|
||||
let uniqueId = getUniqueId(id, usedIds); // Handle duplicate IDs by adding a numeric suffix
|
||||
usedIds.add(uniqueId);
|
||||
|
||||
|
||||
(node as ExtendedElement).properties = {
|
||||
...((node as ExtendedElement).properties || {}),
|
||||
id: uniqueId
|
||||
id: uniqueId,
|
||||
};
|
||||
}
|
||||
}
|
||||
}) as BuildVisitor<Root, 'element'>);
|
||||
}) as BuildVisitor<Root, "element">);
|
||||
return tree;
|
||||
};
|
||||
}) as Plugin<[], Root>,
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,69 +1,73 @@
|
||||
// Used to calculate transfer speed, supports multiple peerIds
|
||||
export class SpeedCalculator {
|
||||
private speeds: Map<string, number>; //peerId,speed
|
||||
private windowSize: number = 2; // 5-second sliding window
|
||||
private transferHistory: Map<string, Array<{ time: number; totalBytes: number }>>; //peerId={time, totalBytes}
|
||||
private maxSpeed: number = 1024 * 1024; // Maximum speed limit (KB/s)
|
||||
private lastUpdateTimes: Map<string, number>; // Record the last update time for each peerId
|
||||
private updateInterval: number = 100; // Minimum update interval (ms)
|
||||
|
||||
constructor() {
|
||||
this.speeds = new Map();
|
||||
this.transferHistory = new Map();
|
||||
this.lastUpdateTimes = new Map();
|
||||
private speeds: Map<string, number>; //peerId,speed
|
||||
private windowSize: number = 2; // 5-second sliding window
|
||||
private transferHistory: Map<
|
||||
string,
|
||||
Array<{ time: number; totalBytes: number }>
|
||||
>; //peerId={time, totalBytes}
|
||||
private maxSpeed: number = 1024 * 1024; // Maximum speed limit (KB/s)
|
||||
private lastUpdateTimes: Map<string, number>; // Record the last update time for each peerId
|
||||
private updateInterval: number = 100; // Minimum update interval (ms)
|
||||
|
||||
constructor() {
|
||||
this.speeds = new Map();
|
||||
this.transferHistory = new Map();
|
||||
this.lastUpdateTimes = new Map();
|
||||
}
|
||||
|
||||
updateSendSpeed(peerId: string, totalBytesSent: number): void {
|
||||
const now = Date.now();
|
||||
|
||||
// Check if the update interval has been reached
|
||||
const lastUpdate = this.lastUpdateTimes.get(peerId) || 0;
|
||||
if (now - lastUpdate < this.updateInterval) {
|
||||
return; // If the interval is too short, return directly
|
||||
}
|
||||
|
||||
updateSendSpeed(peerId: string, totalBytesSent: number): void {
|
||||
const now = Date.now();
|
||||
|
||||
// Check if the update interval has been reached
|
||||
const lastUpdate = this.lastUpdateTimes.get(peerId) || 0;
|
||||
if (now - lastUpdate < this.updateInterval) {
|
||||
return; // If the interval is too short, return directly
|
||||
}
|
||||
|
||||
// Initialize or get transfer history
|
||||
if (!this.transferHistory.has(peerId)) {
|
||||
this.transferHistory.set(peerId, []);
|
||||
}
|
||||
const history = this.transferHistory.get(peerId)!;
|
||||
|
||||
// Add a new cumulative transfer record
|
||||
history.push({ time: now, totalBytes: totalBytesSent });
|
||||
|
||||
// Remove old data outside the window
|
||||
const windowStart = now - this.windowSize * 1000;
|
||||
|
||||
while (history.length > 0 && history[0].time < windowStart) {
|
||||
history.shift();
|
||||
}
|
||||
|
||||
// Calculate the total transfer amount and time difference within the window
|
||||
if (history.length > 1) {
|
||||
// Use the first and last points within the window to calculate speed
|
||||
const firstRecord = history[0];
|
||||
const lastRecord = history[history.length - 1];
|
||||
|
||||
const bytesDiff = lastRecord.totalBytes - firstRecord.totalBytes;
|
||||
const timeSpan = (lastRecord.time - firstRecord.time) / 1000; // Convert to seconds
|
||||
|
||||
// Calculate speed (KB/s) and apply limits
|
||||
let speed = timeSpan > 0 ? bytesDiff / 1024 / timeSpan : 0;
|
||||
speed = Math.min(speed, this.maxSpeed);
|
||||
|
||||
// Reduce the smoothing factor to make the speed react faster to changes
|
||||
const oldSpeed = this.speeds.get(peerId) || 0;
|
||||
const smoothingFactor = 0.3; // Reduce the smoothing factor
|
||||
const smoothedSpeed = oldSpeed * (1 - smoothingFactor) + speed * smoothingFactor;
|
||||
|
||||
this.speeds.set(peerId, smoothedSpeed);
|
||||
}
|
||||
|
||||
// Update the last update time
|
||||
this.lastUpdateTimes.set(peerId, now);
|
||||
|
||||
// Initialize or get transfer history
|
||||
if (!this.transferHistory.has(peerId)) {
|
||||
this.transferHistory.set(peerId, []);
|
||||
}
|
||||
|
||||
getSendSpeed(peerId: string): number {
|
||||
return this.speeds.get(peerId) || 0;
|
||||
const history = this.transferHistory.get(peerId)!;
|
||||
|
||||
// Add a new cumulative transfer record
|
||||
history.push({ time: now, totalBytes: totalBytesSent });
|
||||
|
||||
// Remove old data outside the window
|
||||
const windowStart = now - this.windowSize * 1000;
|
||||
|
||||
while (history.length > 0 && history[0].time < windowStart) {
|
||||
history.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the total transfer amount and time difference within the window
|
||||
if (history.length > 1) {
|
||||
// Use the first and last points within the window to calculate speed
|
||||
const firstRecord = history[0];
|
||||
const lastRecord = history[history.length - 1];
|
||||
|
||||
const bytesDiff = lastRecord.totalBytes - firstRecord.totalBytes;
|
||||
const timeSpan = (lastRecord.time - firstRecord.time) / 1000; // Convert to seconds
|
||||
|
||||
// Calculate speed (KB/s) and apply limits
|
||||
let speed = timeSpan > 0 ? bytesDiff / 1024 / timeSpan : 0;
|
||||
speed = Math.min(speed, this.maxSpeed);
|
||||
|
||||
// Reduce the smoothing factor to make the speed react faster to changes
|
||||
const oldSpeed = this.speeds.get(peerId) || 0;
|
||||
const smoothingFactor = 0.3; // Reduce the smoothing factor
|
||||
const smoothedSpeed =
|
||||
oldSpeed * (1 - smoothingFactor) + speed * smoothingFactor;
|
||||
|
||||
this.speeds.set(peerId, smoothedSpeed);
|
||||
}
|
||||
|
||||
// Update the last update time
|
||||
this.lastUpdateTimes.set(peerId, now);
|
||||
}
|
||||
|
||||
getSendSpeed(peerId: string): number {
|
||||
return this.speeds.get(peerId) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
+17
-17
@@ -1,20 +1,20 @@
|
||||
import { setTrack } from '@/app/config/api';
|
||||
import { setTrack } from "@/app/config/api";
|
||||
// The website tracks the source through ?ref=reddit..., here to get the source, for example https://yourdomain.com?ref=producthunt
|
||||
export const trackReferrer = async () => {
|
||||
// Get URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
let ref = urlParams.get('ref');
|
||||
if (process.env.NEXT_PUBLIC_development === 'false'){
|
||||
ref = urlParams.get('ref') || 'noRef';// Production environment, count daily active users, record as noRef if there is no ref
|
||||
// Get URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
let ref = urlParams.get("ref");
|
||||
if (process.env.NEXT_PUBLIC_development === "false") {
|
||||
ref = urlParams.get("ref") || "noRef"; // Production environment, count daily active users, record as noRef if there is no ref
|
||||
}
|
||||
|
||||
if (ref) {
|
||||
try {
|
||||
setTrack(ref);
|
||||
// Optional: Store the source in localStorage for subsequent tracking
|
||||
// localStorage.setItem('initial_ref', ref);
|
||||
} catch (error) {
|
||||
console.error("Failed to track referrer:", error);
|
||||
}
|
||||
const path = window.location.pathname;
|
||||
if (ref) {
|
||||
try {
|
||||
setTrack(ref,path);
|
||||
// Optional: Store the source in localStorage for subsequent tracking
|
||||
// localStorage.setItem('initial_ref', ref);
|
||||
} catch (error) {
|
||||
console.error('Failed to track referrer:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
@@ -1,53 +1,60 @@
|
||||
// When an Android device switches to another app, the screen remains awake, and the WebRTC connection will not be disconnected.
|
||||
// When an Android device switches to another app, the screen remains awake, and the WebRTC connection will not be disconnected.
|
||||
// Note that this will increase the device's power consumption, so it is important to release the wake lock in time when the connection is disconnected.
|
||||
export class WakeLockManager {
|
||||
private wakeLock: WakeLockSentinel | null = null;
|
||||
private isSupported: boolean = false;
|
||||
private wakeLock: WakeLockSentinel | null = null;
|
||||
private isSupported: boolean = false;
|
||||
|
||||
constructor() {
|
||||
// Check if the browser supports the 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') {// Only request when the page is visible
|
||||
console.warn('Wake Lock API should request in visible state');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Request screen wake lock
|
||||
this.wakeLock = await navigator.wakeLock.request('screen');
|
||||
|
||||
// Listen for the visibilitychange event and re-request the wake lock when the page becomes visible again
|
||||
document.addEventListener('visibilitychange', this.handleVisibilityChange);
|
||||
constructor() {
|
||||
// Check if the browser supports the Wake Lock API
|
||||
this.isSupported = "wakeLock" in navigator;
|
||||
}
|
||||
|
||||
console.log('Wake Lock is active');
|
||||
} catch (err) {
|
||||
console.error('Error requesting wake lock:', err);
|
||||
}
|
||||
async requestWakeLock(): Promise<void> {
|
||||
if (!this.isSupported) {
|
||||
console.warn("Wake Lock API is not supported in this browser");
|
||||
return;
|
||||
}
|
||||
|
||||
private handleVisibilityChange = async () => {
|
||||
if (document.visibilityState === 'visible' && this.wakeLock === null) {
|
||||
// When the page becomes visible again, re-request the 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);
|
||||
}
|
||||
if (document.visibilityState !== "visible") {
|
||||
// Only request when the page is visible
|
||||
console.warn("Wake Lock API should request in visible state");
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Request screen wake lock
|
||||
this.wakeLock = await navigator.wakeLock.request("screen");
|
||||
|
||||
// Listen for the visibilitychange event and re-request the wake lock when the page becomes visible again
|
||||
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) {
|
||||
// When the page becomes visible again, re-request the 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,7 +363,8 @@ export default class BaseWebRTC {
|
||||
this.onDataReceived?.(event.data, peerId);
|
||||
};
|
||||
|
||||
dataChannel.onclose = () => this.log("log", `Data channel with ${peerId} closed.`);
|
||||
dataChannel.onclose = () =>
|
||||
this.log("log", `Data channel with ${peerId} closed.`);
|
||||
}
|
||||
// Join a room. sendInitiatorOnline indicates whether to send "initiator online" message after joining.
|
||||
public async joinRoom(
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { Config } from "tailwindcss"
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./pages/**/*.{ts,tsx}',
|
||||
'./components/**/*.{ts,tsx}',
|
||||
'./app/**/*.{ts,tsx}',
|
||||
'./src/**/*.{ts,tsx}',
|
||||
],
|
||||
"./pages/**/*.{ts,tsx}",
|
||||
"./components/**/*.{ts,tsx}",
|
||||
"./app/**/*.{ts,tsx}",
|
||||
"./src/**/*.{ts,tsx}",
|
||||
],
|
||||
prefix: "",
|
||||
theme: {
|
||||
container: {
|
||||
@@ -75,6 +75,6 @@ const config = {
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
} satisfies Config
|
||||
} satisfies Config;
|
||||
|
||||
export default config
|
||||
export default config;
|
||||
|
||||
Vendored
+3
-3
@@ -1,4 +1,4 @@
|
||||
declare module 'lodash';
|
||||
declare module "lodash";
|
||||
interface Window {
|
||||
showDirectoryPicker?: () => Promise<FileSystemDirectoryHandle>;
|
||||
}
|
||||
showDirectoryPicker?: () => Promise<FileSystemDirectoryHandle>;
|
||||
}
|
||||
|
||||
+290
-290
@@ -1,293 +1,293 @@
|
||||
// types/messages.ts
|
||||
|
||||
export type MetaData = {
|
||||
title: string;
|
||||
description: string;
|
||||
keywords?: string;
|
||||
};
|
||||
|
||||
export type Meta = {
|
||||
home: MetaData;
|
||||
about: MetaData;
|
||||
faq: MetaData;
|
||||
help: MetaData;
|
||||
privacy: MetaData;
|
||||
terms: MetaData;
|
||||
};
|
||||
|
||||
export type Header = {
|
||||
Home_dis: string;
|
||||
Blog_dis: string;
|
||||
About_dis: string;
|
||||
Help_dis: string;
|
||||
FAQ_dis: string;
|
||||
Terms_dis: string;
|
||||
Privacy_dis: string;
|
||||
};
|
||||
|
||||
export type Footer = {
|
||||
CopyrightNotice: string;
|
||||
Terms_dis: string;
|
||||
Privacy_dis: string;
|
||||
SupportedLanguages: string;
|
||||
};
|
||||
|
||||
export type Privacy = {
|
||||
PrivacyPolicy_dis: string;
|
||||
h1: string;
|
||||
h1_P: string;
|
||||
h2_1: string;
|
||||
h2_1_P: string;
|
||||
h2_2: string;
|
||||
h2_2_P: string;
|
||||
h2_3: string;
|
||||
h2_3_P: string;
|
||||
h2_4: string;
|
||||
h2_4_P: string;
|
||||
h2_5: string;
|
||||
h2_5_P: string;
|
||||
};
|
||||
|
||||
export type Terms = {
|
||||
TermsOfUse_dis: string;
|
||||
h1: string;
|
||||
h1_P: string;
|
||||
h2_1: string;
|
||||
h2_1_P: string;
|
||||
h2_2: string;
|
||||
h2_2_P: string;
|
||||
h2_3: string;
|
||||
h2_3_P: string;
|
||||
h2_4: string;
|
||||
h2_4_P: string;
|
||||
h2_5: string;
|
||||
h2_5_P: string;
|
||||
};
|
||||
|
||||
export type Help = {
|
||||
Help_dis: string;
|
||||
h1: string;
|
||||
h1_P: string;
|
||||
h2_1: string;
|
||||
h2_1_P1: string;
|
||||
h2_1_P2: string;
|
||||
h2_2: string;
|
||||
h2_2_P: string;
|
||||
h2_3: string;
|
||||
h2_3_P: string;
|
||||
};
|
||||
|
||||
export type About = {
|
||||
h1: string;
|
||||
P1: string;
|
||||
P2: string;
|
||||
P3: string;
|
||||
P4: string;
|
||||
P5: string;
|
||||
};
|
||||
|
||||
export type HowItWorks = {
|
||||
h2: string;
|
||||
h2_P: string;
|
||||
btn_try: string;
|
||||
step1_title: string;
|
||||
step1_description: string;
|
||||
step2_title: string;
|
||||
step2_description: string;
|
||||
step3_title: string;
|
||||
step3_description: string;
|
||||
};
|
||||
|
||||
export type SystemDiagram = {
|
||||
h2: string;
|
||||
h2_P: string;
|
||||
};
|
||||
|
||||
export type KeyFeatures = {
|
||||
h2: string;
|
||||
h3_1: string;
|
||||
h3_1_P: string;
|
||||
h3_2: string;
|
||||
h3_2_P: string;
|
||||
h3_3: string;
|
||||
h3_3_P: string;
|
||||
h3_4: string;
|
||||
h3_4_P: string;
|
||||
h3_5: string;
|
||||
h3_5_P: string;
|
||||
};
|
||||
|
||||
export type FAQ = {
|
||||
FAQ_dis: string;
|
||||
question_0: string;
|
||||
answer_0: string;
|
||||
question_1: string;
|
||||
answer_1: string;
|
||||
question_2: string;
|
||||
answer_2: string;
|
||||
question_3: string;
|
||||
answer_3: string;
|
||||
question_4: string;
|
||||
answer_4: string;
|
||||
question_5: string;
|
||||
answer_5: string;
|
||||
question_6: string;
|
||||
answer_6: string;
|
||||
question_7: string;
|
||||
answer_7: string;
|
||||
question_8: string;
|
||||
answer_8: string;
|
||||
question_9: string;
|
||||
answer_9: string;
|
||||
question_10: string;
|
||||
answer_10: string;
|
||||
question_11: string;
|
||||
answer_11: string;
|
||||
question_12: string;
|
||||
answer_12: string;
|
||||
question_13: string;
|
||||
answer_13: string;
|
||||
};
|
||||
|
||||
export type ClipboardBtn = {
|
||||
Pasted_dis: string;
|
||||
Copied_dis: string;
|
||||
};
|
||||
|
||||
export type FileUploadHandler = {
|
||||
NoFileChosen_tips: string;
|
||||
fileChosen_tips_template: string;
|
||||
Drag_tips: string;
|
||||
chosenDiagTitle: string;
|
||||
chosenDiagDescription: string;
|
||||
SelectFile_dis: string;
|
||||
SelectFolder_dis: string;
|
||||
};
|
||||
|
||||
export type FileTransferButton = {
|
||||
SavedToDisk_tips: string;
|
||||
CurrentFileTransferring_tips: string;
|
||||
OtherFileTransferring_tips: string;
|
||||
download_tips: string;
|
||||
Saved_dis: string;
|
||||
Waiting_dis: string;
|
||||
Download_dis: string;
|
||||
};
|
||||
|
||||
export type FileListDisplay = {
|
||||
sending_dis: string;
|
||||
receiving_dis: string;
|
||||
finish_dis: string;
|
||||
delete_dis: string;
|
||||
downloadNum_dis: string;
|
||||
folder_tips_template: string;
|
||||
folder_dis_template: string;
|
||||
PopupDialog_title: string;
|
||||
PopupDialog_description: string;
|
||||
chooseSavePath_tips: string;
|
||||
chooseSavePath_dis: string;
|
||||
};
|
||||
|
||||
export type RetrieveMethod = {
|
||||
P: string;
|
||||
RoomId_tips: string;
|
||||
copyRoomId_tips: string;
|
||||
url_tips: string;
|
||||
copyUrl_tips: string;
|
||||
scanQR_tips: string;
|
||||
Copied_dis: string;
|
||||
Copy_QR_dis: string;
|
||||
download_QR_dis: string;
|
||||
};
|
||||
|
||||
export type RoomCheck = {
|
||||
empty_msg: string;
|
||||
available_msg: string;
|
||||
notAvailable_msg: string;
|
||||
};
|
||||
|
||||
export type JoinRoom = {
|
||||
EmptyMsg: string;
|
||||
DuplicateMsg: string;
|
||||
successMsg: string;
|
||||
notExist: string;
|
||||
failMsg: string;
|
||||
};
|
||||
|
||||
export type RoomStatus = {
|
||||
senderEmptyMsg: string;
|
||||
receiverEmptyMsg: string;
|
||||
onlyOneMsg: string;
|
||||
peopleMsg_template: string;
|
||||
connected_dis: string;
|
||||
};
|
||||
|
||||
export type ClipboardAppHtml = {
|
||||
senderTab: string;
|
||||
retrieveTab: string;
|
||||
shareTitle_dis: string;
|
||||
retrieveTitle_dis: string;
|
||||
RoomStatus_dis: string;
|
||||
Paste_dis: string;
|
||||
Copy_dis: string;
|
||||
inputRoomIdprompt: string;
|
||||
joinRoomBtn: string;
|
||||
startSendingBtn: string;
|
||||
readClipboardToRoomId: string;
|
||||
enterRoomID_placeholder: string;
|
||||
retrieveMethod: string;
|
||||
inputRoomId_tips: string;
|
||||
joinRoom_dis: string;
|
||||
startSending_loadingText: string;
|
||||
startSending_dis: string;
|
||||
readClipboard_dis: string;
|
||||
retrieveRoomId_placeholder: string;
|
||||
RetrieveMethodTitle: string;
|
||||
};
|
||||
|
||||
export type ClipboardApp = {
|
||||
fetchRoom_err: string;
|
||||
roomCheck: RoomCheck;
|
||||
channelOpen_msg: string;
|
||||
waitting_tips: string;
|
||||
joinRoom: JoinRoom;
|
||||
pickSaveMsg: string;
|
||||
roomStatus: RoomStatus;
|
||||
html: ClipboardAppHtml;
|
||||
};
|
||||
|
||||
export type Home = {
|
||||
h1: string;
|
||||
h1P: string;
|
||||
h2_screenOnly: string;
|
||||
h2_demo: string;
|
||||
h2P_demo: string;
|
||||
watch_tips: string;
|
||||
youtube_tips: string;
|
||||
bilibili_tips: string;
|
||||
};
|
||||
|
||||
export type Text = {
|
||||
Header: Header;
|
||||
Footer: Footer;
|
||||
privacy: Privacy;
|
||||
terms: Terms;
|
||||
help: Help;
|
||||
about: About;
|
||||
HowItWorks: HowItWorks;
|
||||
SystemDiagram: SystemDiagram;
|
||||
KeyFeatures: KeyFeatures;
|
||||
faqs: FAQ;
|
||||
clipboard_btn: ClipboardBtn;
|
||||
fileUploadHandler: FileUploadHandler;
|
||||
FileTransferButton: FileTransferButton;
|
||||
FileListDisplay: FileListDisplay;
|
||||
RetrieveMethod: RetrieveMethod;
|
||||
ClipboardApp: ClipboardApp;
|
||||
home: Home;
|
||||
};
|
||||
|
||||
export type Messages = {
|
||||
meta: Meta;
|
||||
text: Text;
|
||||
};
|
||||
title: string;
|
||||
description: string;
|
||||
keywords?: string;
|
||||
};
|
||||
|
||||
export type Meta = {
|
||||
home: MetaData;
|
||||
about: MetaData;
|
||||
faq: MetaData;
|
||||
help: MetaData;
|
||||
privacy: MetaData;
|
||||
terms: MetaData;
|
||||
};
|
||||
|
||||
export type Header = {
|
||||
Home_dis: string;
|
||||
Blog_dis: string;
|
||||
About_dis: string;
|
||||
Help_dis: string;
|
||||
FAQ_dis: string;
|
||||
Terms_dis: string;
|
||||
Privacy_dis: string;
|
||||
};
|
||||
|
||||
export type Footer = {
|
||||
CopyrightNotice: string;
|
||||
Terms_dis: string;
|
||||
Privacy_dis: string;
|
||||
SupportedLanguages: string;
|
||||
};
|
||||
|
||||
export type Privacy = {
|
||||
PrivacyPolicy_dis: string;
|
||||
h1: string;
|
||||
h1_P: string;
|
||||
h2_1: string;
|
||||
h2_1_P: string;
|
||||
h2_2: string;
|
||||
h2_2_P: string;
|
||||
h2_3: string;
|
||||
h2_3_P: string;
|
||||
h2_4: string;
|
||||
h2_4_P: string;
|
||||
h2_5: string;
|
||||
h2_5_P: string;
|
||||
};
|
||||
|
||||
export type Terms = {
|
||||
TermsOfUse_dis: string;
|
||||
h1: string;
|
||||
h1_P: string;
|
||||
h2_1: string;
|
||||
h2_1_P: string;
|
||||
h2_2: string;
|
||||
h2_2_P: string;
|
||||
h2_3: string;
|
||||
h2_3_P: string;
|
||||
h2_4: string;
|
||||
h2_4_P: string;
|
||||
h2_5: string;
|
||||
h2_5_P: string;
|
||||
};
|
||||
|
||||
export type Help = {
|
||||
Help_dis: string;
|
||||
h1: string;
|
||||
h1_P: string;
|
||||
h2_1: string;
|
||||
h2_1_P1: string;
|
||||
h2_1_P2: string;
|
||||
h2_2: string;
|
||||
h2_2_P: string;
|
||||
h2_3: string;
|
||||
h2_3_P: string;
|
||||
};
|
||||
|
||||
export type About = {
|
||||
h1: string;
|
||||
P1: string;
|
||||
P2: string;
|
||||
P3: string;
|
||||
P4: string;
|
||||
P5: string;
|
||||
};
|
||||
|
||||
export type HowItWorks = {
|
||||
h2: string;
|
||||
h2_P: string;
|
||||
btn_try: string;
|
||||
step1_title: string;
|
||||
step1_description: string;
|
||||
step2_title: string;
|
||||
step2_description: string;
|
||||
step3_title: string;
|
||||
step3_description: string;
|
||||
};
|
||||
|
||||
export type SystemDiagram = {
|
||||
h2: string;
|
||||
h2_P: string;
|
||||
};
|
||||
|
||||
export type KeyFeatures = {
|
||||
h2: string;
|
||||
h3_1: string;
|
||||
h3_1_P: string;
|
||||
h3_2: string;
|
||||
h3_2_P: string;
|
||||
h3_3: string;
|
||||
h3_3_P: string;
|
||||
h3_4: string;
|
||||
h3_4_P: string;
|
||||
h3_5: string;
|
||||
h3_5_P: string;
|
||||
};
|
||||
|
||||
export type FAQ = {
|
||||
FAQ_dis: string;
|
||||
question_0: string;
|
||||
answer_0: string;
|
||||
question_1: string;
|
||||
answer_1: string;
|
||||
question_2: string;
|
||||
answer_2: string;
|
||||
question_3: string;
|
||||
answer_3: string;
|
||||
question_4: string;
|
||||
answer_4: string;
|
||||
question_5: string;
|
||||
answer_5: string;
|
||||
question_6: string;
|
||||
answer_6: string;
|
||||
question_7: string;
|
||||
answer_7: string;
|
||||
question_8: string;
|
||||
answer_8: string;
|
||||
question_9: string;
|
||||
answer_9: string;
|
||||
question_10: string;
|
||||
answer_10: string;
|
||||
question_11: string;
|
||||
answer_11: string;
|
||||
question_12: string;
|
||||
answer_12: string;
|
||||
question_13: string;
|
||||
answer_13: string;
|
||||
};
|
||||
|
||||
export type ClipboardBtn = {
|
||||
Pasted_dis: string;
|
||||
Copied_dis: string;
|
||||
};
|
||||
|
||||
export type FileUploadHandler = {
|
||||
NoFileChosen_tips: string;
|
||||
fileChosen_tips_template: string;
|
||||
Drag_tips: string;
|
||||
chosenDiagTitle: string;
|
||||
chosenDiagDescription: string;
|
||||
SelectFile_dis: string;
|
||||
SelectFolder_dis: string;
|
||||
};
|
||||
|
||||
export type FileTransferButton = {
|
||||
SavedToDisk_tips: string;
|
||||
CurrentFileTransferring_tips: string;
|
||||
OtherFileTransferring_tips: string;
|
||||
download_tips: string;
|
||||
Saved_dis: string;
|
||||
Waiting_dis: string;
|
||||
Download_dis: string;
|
||||
};
|
||||
|
||||
export type FileListDisplay = {
|
||||
sending_dis: string;
|
||||
receiving_dis: string;
|
||||
finish_dis: string;
|
||||
delete_dis: string;
|
||||
downloadNum_dis: string;
|
||||
folder_tips_template: string;
|
||||
folder_dis_template: string;
|
||||
PopupDialog_title: string;
|
||||
PopupDialog_description: string;
|
||||
chooseSavePath_tips: string;
|
||||
chooseSavePath_dis: string;
|
||||
};
|
||||
|
||||
export type RetrieveMethod = {
|
||||
P: string;
|
||||
RoomId_tips: string;
|
||||
copyRoomId_tips: string;
|
||||
url_tips: string;
|
||||
copyUrl_tips: string;
|
||||
scanQR_tips: string;
|
||||
Copied_dis: string;
|
||||
Copy_QR_dis: string;
|
||||
download_QR_dis: string;
|
||||
};
|
||||
|
||||
export type RoomCheck = {
|
||||
empty_msg: string;
|
||||
available_msg: string;
|
||||
notAvailable_msg: string;
|
||||
};
|
||||
|
||||
export type JoinRoom = {
|
||||
EmptyMsg: string;
|
||||
DuplicateMsg: string;
|
||||
successMsg: string;
|
||||
notExist: string;
|
||||
failMsg: string;
|
||||
};
|
||||
|
||||
export type RoomStatus = {
|
||||
senderEmptyMsg: string;
|
||||
receiverEmptyMsg: string;
|
||||
onlyOneMsg: string;
|
||||
peopleMsg_template: string;
|
||||
connected_dis: string;
|
||||
};
|
||||
|
||||
export type ClipboardAppHtml = {
|
||||
senderTab: string;
|
||||
retrieveTab: string;
|
||||
shareTitle_dis: string;
|
||||
retrieveTitle_dis: string;
|
||||
RoomStatus_dis: string;
|
||||
Paste_dis: string;
|
||||
Copy_dis: string;
|
||||
inputRoomIdprompt: string;
|
||||
joinRoomBtn: string;
|
||||
startSendingBtn: string;
|
||||
readClipboardToRoomId: string;
|
||||
enterRoomID_placeholder: string;
|
||||
retrieveMethod: string;
|
||||
inputRoomId_tips: string;
|
||||
joinRoom_dis: string;
|
||||
startSending_loadingText: string;
|
||||
startSending_dis: string;
|
||||
readClipboard_dis: string;
|
||||
retrieveRoomId_placeholder: string;
|
||||
RetrieveMethodTitle: string;
|
||||
};
|
||||
|
||||
export type ClipboardApp = {
|
||||
fetchRoom_err: string;
|
||||
roomCheck: RoomCheck;
|
||||
channelOpen_msg: string;
|
||||
waitting_tips: string;
|
||||
joinRoom: JoinRoom;
|
||||
pickSaveMsg: string;
|
||||
roomStatus: RoomStatus;
|
||||
html: ClipboardAppHtml;
|
||||
};
|
||||
|
||||
export type Home = {
|
||||
h1: string;
|
||||
h1P: string;
|
||||
h2_screenOnly: string;
|
||||
h2_demo: string;
|
||||
h2P_demo: string;
|
||||
watch_tips: string;
|
||||
youtube_tips: string;
|
||||
bilibili_tips: string;
|
||||
};
|
||||
|
||||
export type Text = {
|
||||
Header: Header;
|
||||
Footer: Footer;
|
||||
privacy: Privacy;
|
||||
terms: Terms;
|
||||
help: Help;
|
||||
about: About;
|
||||
HowItWorks: HowItWorks;
|
||||
SystemDiagram: SystemDiagram;
|
||||
KeyFeatures: KeyFeatures;
|
||||
faqs: FAQ;
|
||||
clipboard_btn: ClipboardBtn;
|
||||
fileUploadHandler: FileUploadHandler;
|
||||
FileTransferButton: FileTransferButton;
|
||||
FileListDisplay: FileListDisplay;
|
||||
RetrieveMethod: RetrieveMethod;
|
||||
ClipboardApp: ClipboardApp;
|
||||
home: Home;
|
||||
};
|
||||
|
||||
export type Messages = {
|
||||
meta: Meta;
|
||||
text: Text;
|
||||
};
|
||||
|
||||
+12
-10
@@ -1,18 +1,20 @@
|
||||
export const slugifyTag = (tag: string): string => {
|
||||
// Use encodeURIComponent to handle Chinese and special characters
|
||||
return encodeURIComponent(tag
|
||||
.trim()
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/\-\-+/g, '-') // Replace multiple hyphens with a single one
|
||||
.replace(/^-+/, '') // Remove leading hyphens
|
||||
.replace(/-+$/, '') // Remove trailing hyphens
|
||||
return encodeURIComponent(
|
||||
tag
|
||||
.trim()
|
||||
.replace(/\s+/g, "-") // Replace spaces with hyphens
|
||||
.replace(/\-\-+/g, "-") // Replace multiple hyphens with a single one
|
||||
.replace(/^-+/, "") // Remove leading hyphens
|
||||
.replace(/-+$/, "") // Remove trailing hyphens
|
||||
);
|
||||
};
|
||||
|
||||
export const unslugifyTag = (slug: string): string => {
|
||||
// Decode URL-encoded tags
|
||||
return decodeURIComponent(slug
|
||||
.replace(/-/g, ' ') // Replace hyphens back with spaces
|
||||
.trim()
|
||||
return decodeURIComponent(
|
||||
slug
|
||||
.replace(/-/g, " ") // Replace hyphens back with spaces
|
||||
.trim()
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user