feat: tính năng chia sẻ khẩn cấp
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
const loadScript = (src: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (document.querySelector(`script[src="${src}"]`)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error(`Failed to load script ${src}`));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
};
|
||||
|
||||
const loadModerationLibraries = async () => {
|
||||
// Load TensorFlow first
|
||||
await loadScript('https://cdn.jsdelivr.net/npm/@tensorflow/tfjs');
|
||||
// Load models after tfjs is available
|
||||
await Promise.all([
|
||||
loadScript('https://cdn.jsdelivr.net/npm/@tensorflow-models/blazeface'),
|
||||
loadScript('https://cdn.jsdelivr.net/npm/nsfwjs@2.4.0/dist/bundle.js')
|
||||
]);
|
||||
};
|
||||
|
||||
export const processImageModeration = async (file: File): Promise<{ file: File; blocked: boolean }> => {
|
||||
try {
|
||||
const settingsRes = await fetch('/api/v1/moderation/settings');
|
||||
if (!settingsRes.ok) return { file, blocked: false };
|
||||
const settings = await settingsRes.json();
|
||||
const { blockNsfw, blurFaces } = settings;
|
||||
|
||||
if (!blockNsfw && !blurFaces) {
|
||||
return { file, blocked: false };
|
||||
}
|
||||
|
||||
await loadModerationLibraries();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = async () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
resolve({ file, blocked: false });
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
if (blockNsfw) {
|
||||
try {
|
||||
const nsfwModel = await (window as any).nsfwjs.load();
|
||||
const predictions = await nsfwModel.classify(canvas);
|
||||
const pornOrHentai = predictions.find((p: any) => p.className === 'Porn' || p.className === 'Hentai');
|
||||
const pornProb = pornOrHentai ? pornOrHentai.probability : 0;
|
||||
if (pornProb > 0.5) {
|
||||
resolve({ file, blocked: true });
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('NSFW validation error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
let modified = false;
|
||||
if (blurFaces) {
|
||||
try {
|
||||
const blazefaceModel = await (window as any).blazeface.load();
|
||||
const predictions = await blazefaceModel.estimateFaces(canvas, false);
|
||||
if (predictions && predictions.length > 0) {
|
||||
modified = true;
|
||||
predictions.forEach((prediction: any) => {
|
||||
const startX = prediction.topLeft[0];
|
||||
const startY = prediction.topLeft[1];
|
||||
const endX = prediction.bottomRight[0];
|
||||
const endY = prediction.bottomRight[1];
|
||||
const width = endX - startX;
|
||||
const height = endY - startY;
|
||||
|
||||
const faceCanvas = document.createElement('canvas');
|
||||
faceCanvas.width = width;
|
||||
faceCanvas.height = height;
|
||||
const faceCtx = faceCanvas.getContext('2d');
|
||||
if (faceCtx) {
|
||||
faceCtx.drawImage(canvas, startX, startY, width, height, 0, 0, width, height);
|
||||
ctx.filter = 'blur(15px)';
|
||||
ctx.drawImage(faceCanvas, startX, startY, width, height);
|
||||
ctx.filter = 'none';
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Face blur error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const processedFile = new File([blob], file.name, { type: file.type });
|
||||
resolve({ file: processedFile, blocked: false });
|
||||
} else {
|
||||
resolve({ file, blocked: false });
|
||||
}
|
||||
}, file.type);
|
||||
} else {
|
||||
resolve({ file, blocked: false });
|
||||
}
|
||||
};
|
||||
img.onerror = () => {
|
||||
resolve({ file, blocked: false });
|
||||
};
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Image moderation failed:', err);
|
||||
return { file, blocked: false };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
export const useTheme = () => {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
return (localStorage.getItem('theme') as Theme) || 'system';
|
||||
});
|
||||
|
||||
const applyTheme = (currentTheme: Theme) => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
|
||||
if (currentTheme === 'dark') {
|
||||
root.classList.add('dark');
|
||||
root.style.colorScheme = 'dark';
|
||||
} else if (currentTheme === 'light') {
|
||||
root.classList.add('light');
|
||||
root.style.colorScheme = 'light';
|
||||
} else {
|
||||
// System
|
||||
const systemIsDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (systemIsDark) {
|
||||
root.classList.add('dark');
|
||||
root.style.colorScheme = 'dark';
|
||||
} else {
|
||||
root.classList.add('light');
|
||||
root.style.colorScheme = 'light';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const changeTheme = (newTheme: Theme) => {
|
||||
localStorage.setItem('theme', newTheme);
|
||||
setTheme(newTheme);
|
||||
applyTheme(newTheme);
|
||||
window.dispatchEvent(new Event('themeChange'));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
|
||||
// Listen for system theme changes if theme is set to 'system'
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handleSystemThemeChange = () => {
|
||||
if (localStorage.getItem('theme') === 'system' || !localStorage.getItem('theme')) {
|
||||
applyTheme('system');
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleSystemThemeChange);
|
||||
|
||||
const handleStorageChange = () => {
|
||||
const storedTheme = (localStorage.getItem('theme') as Theme) || 'system';
|
||||
setTheme(storedTheme);
|
||||
applyTheme(storedTheme);
|
||||
};
|
||||
|
||||
window.addEventListener('themeChange', handleStorageChange);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleSystemThemeChange);
|
||||
window.removeEventListener('themeChange', handleStorageChange);
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
return { theme, changeTheme };
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export type Language = 'vi' | 'en' | 'zh';
|
||||
|
||||
const translations: Record<string, Record<Language, string>> = {
|
||||
// Common
|
||||
appName: { vi: 'Travel Planner', en: 'Travel Planner', zh: '旅行规划' },
|
||||
login: { vi: 'Đăng nhập', en: 'Log In', zh: '登录' },
|
||||
signup: { vi: 'Đăng ký', en: 'Sign Up', zh: '注册' },
|
||||
logout: { vi: 'Đăng xuất', en: 'Log Out', zh: '登出' },
|
||||
cancel: { vi: 'Hủy', en: 'Cancel', zh: '取消' },
|
||||
confirm: { vi: 'Xác nhận', en: 'Confirm', zh: '确认' },
|
||||
save: { vi: 'Lưu', en: 'Save', zh: '保存' },
|
||||
saving: { vi: 'Đang lưu...', en: 'Saving...', zh: '保存中...' },
|
||||
loading: { vi: 'Đang tải...', en: 'Loading...', zh: '加载中...' },
|
||||
success: { vi: 'Thành công', en: 'Success', zh: '成功' },
|
||||
error: { vi: 'Lỗi', en: 'Error', zh: '错误' },
|
||||
info: { vi: 'Thông tin', en: 'Info', zh: '信息' },
|
||||
delete: { vi: 'Xóa', en: 'Delete', zh: '删除' },
|
||||
edit: { vi: 'Chỉnh sửa', en: 'Edit', zh: '编辑' },
|
||||
|
||||
// Landing Page
|
||||
welcomeBack: { vi: 'Chào mừng bạn quay trở lại!', en: 'Welcome back!', zh: '欢迎回来!' },
|
||||
emailLabel: { vi: 'Email', en: 'Email', zh: '邮箱' },
|
||||
passwordLabel: { vi: 'Mật khẩu', en: 'Password', zh: '密码' },
|
||||
forgotPassword: { vi: 'Quên mật khẩu?', en: 'Forgot password?', zh: '忘记密码?' },
|
||||
orLabel: { vi: 'Hoặc', en: 'Or', zh: '或' },
|
||||
noAccount: { vi: 'Chưa có tài khoản?', en: "Don't have an account?", zh: '还没有账号?' },
|
||||
createAccountNow: { vi: 'Tạo tài khoản ngay', en: 'Create one now', zh: '立即注册' },
|
||||
quickCamera: { vi: 'Chụp ảnh nhanh', en: 'Quick Camera', zh: '快速相机' },
|
||||
momentsTitle: { vi: 'Khoảnh khắc cộng đồng', en: 'Community Moments', zh: '社区精彩瞬间' },
|
||||
trustedMembers: { vi: 'Thành viên uy tín', en: 'Trusted Members', zh: '信用会员' },
|
||||
exploreToursBtn: { vi: 'Khám phá các hành trình du lịch', en: 'Explore Travel Itineraries', zh: '探索旅行行程' },
|
||||
|
||||
// Explore Map
|
||||
systemBtn: { vi: 'Hệ thống', en: 'System', zh: '系统管理' },
|
||||
createTourBtn: { vi: 'Tạo Tour', en: 'Create Tour', zh: '创建行程' },
|
||||
chooseLocationMap: { vi: 'Chọn vị trí trên bản đồ', en: 'Choose location on map', zh: '在地图上选择位置' },
|
||||
clickMapSelectCoords: { vi: 'Click lên bản đồ để chọn tọa độ', en: 'Click on map to select coordinates', zh: '在地图上点击以选择坐标' },
|
||||
coordsLabel: { vi: 'Tọa độ', en: 'Coordinates', zh: '坐标' },
|
||||
noCoordsSelected: { vi: 'Chưa chọn vị trí', en: 'No location selected', zh: '未选择位置' },
|
||||
searchPlaceholder: { vi: 'Tìm kiếm địa điểm...', en: 'Search places...', zh: '搜索地点...' },
|
||||
|
||||
// Itinerary Timeline
|
||||
legLabel: { vi: 'Chặng', en: 'Leg', zh: '航段' },
|
||||
legSequence: { vi: 'Chi tiết Chặng', en: 'Leg Details', zh: '航段详情' },
|
||||
startDate: { vi: 'Bắt đầu', en: 'Start Date', zh: '开始日期' },
|
||||
endDate: { vi: 'Kết thúc', en: 'End Date', zh: '结束日期' },
|
||||
addLocation: { vi: 'Thêm địa điểm', en: 'Add Location', zh: '添加地点' },
|
||||
dwellTime: { vi: 'Thời gian dừng', en: 'Dwell Time', zh: '停留时间' },
|
||||
expenseLabel: { vi: 'Chi phí', en: 'Expense', zh: '费用' },
|
||||
paidByLabel: { vi: 'Người chi trả', en: 'Paid By', zh: '付款人' },
|
||||
optimizeBtn: { vi: 'Tối ưu', en: 'Optimize', zh: '优化' },
|
||||
startPoint: { vi: 'Điểm bắt đầu', en: 'Start Point', zh: '起点' },
|
||||
endPoint: { vi: 'Điểm kết thúc', en: 'End Point', zh: '终点' },
|
||||
pinStartHelper: { vi: 'Nhấn để ghim điểm bắt đầu cho Tour...', en: 'Click to pin starting point...', zh: '点击锁定行程起点...' },
|
||||
pinEndHelper: { vi: 'Nhấn để ghim điểm kết thúc cho Tour...', en: 'Click to pin ending point...', zh: '点击锁定行程终点...' },
|
||||
noLegs: { vi: 'Chưa có chặng nào trong lộ trình.', en: 'No legs in the itinerary yet.', zh: '行程中暂无航段。' },
|
||||
declareLegsBtn: { vi: 'Khai báo số chặng', en: 'Declare Leg Count', zh: '申报航段数' },
|
||||
addSingleLeg: { vi: 'Thêm chặng lẻ vào cuối', en: 'Add leg to end', zh: '末尾添加单个航段' },
|
||||
exportPDF: { vi: 'Xuất PDF', en: 'Export PDF', zh: '导出 PDF' },
|
||||
|
||||
// Tour Detail / Organizer Rating
|
||||
rateOrganizer: { vi: 'Đánh giá ban tổ chức', en: 'Rate Organizer', zh: '评估组织者' },
|
||||
honesty: { vi: 'Trung thực', en: 'Honesty', zh: '诚实度' },
|
||||
transparency: { vi: 'Minh bạch', en: 'Transparency', zh: '透明度' },
|
||||
enthusiasm: { vi: 'Nhiệt tình', en: 'Enthusiasm', zh: '热情度' },
|
||||
cheerfulness: { vi: 'Vui vẻ', en: 'Cheerfulness', zh: '愉快度' },
|
||||
seriousness: { vi: 'Nhiêm túc', en: 'Seriousness', zh: '认真度' },
|
||||
planning: { vi: 'Có kế hoạch', en: 'Planning Skills', zh: '计划性' },
|
||||
survival: { vi: 'Kỹ năng sinh tồn', en: 'Survival Skills', zh: '生存技能' },
|
||||
rateTitle: { vi: 'Đánh giá Người tạo Tour', en: 'Rate Tour Creator', zh: '评价行程发起人' },
|
||||
rateCommentPlaceholder: { vi: 'Nhập ý kiến đánh giá khác...', en: 'Enter other comments...', zh: '输入其他评价...' },
|
||||
emergencyShare: { vi: 'Chia sẻ khẩn cấp', en: 'Emergency Share', zh: '紧急分享' },
|
||||
emergencyShareTooltip: { vi: 'Bật chia sẻ để người thân có thể định vị bạn khi khẩn cấp', en: 'Enable sharing so family can locate you in emergencies', zh: '开启分享以便家人在紧急情况下定位您' },
|
||||
copyShareLink: { vi: 'Sao chép liên kết chia sẻ', en: 'Copy share link', zh: '复制分享链接' },
|
||||
|
||||
// User Management / Moderation
|
||||
tabUsers: { vi: 'Người dùng', en: 'Users', zh: '用户管理' },
|
||||
tabPhotos: { vi: 'Ảnh công khai', en: 'Public Photos', zh: '公开照片' },
|
||||
tabTrash: { vi: 'Ảnh rác', en: 'Trash Photos', zh: '垃圾照片' },
|
||||
tabFilters: { vi: 'Bộ lọc', en: 'Filters', zh: '过滤器' },
|
||||
filterNsfwToggle: { vi: 'Lọc hình ảnh khiêu dâm', en: 'Block NSFW Images', zh: '过滤淫秽图片' },
|
||||
filterFaceBlurToggle: { vi: 'Làm mờ khuôn mặt', en: 'Automatic Face Blur', zh: '自动模糊人脸' },
|
||||
wordFiltersTitle: { vi: 'Từ khóa cấm & Thay thế', en: 'Banned Words & Replacements', zh: '禁用词及替换词' },
|
||||
addWordBtn: { vi: 'Thêm từ khóa', en: 'Add Word', zh: '添加词汇' },
|
||||
wordLabel: { vi: 'Từ cấm', en: 'Banned Word', zh: '敏感词' },
|
||||
replacementLabel: { vi: 'Từ thay thế', en: 'Replacement', zh: '替换词' },
|
||||
commentSectionTitle: { vi: 'Bình luận cộng đồng', en: 'Community Comments', zh: '社区评论' },
|
||||
|
||||
// Dashboard / General Settings
|
||||
myItineraries: { vi: 'Hành trình của tôi', en: 'My Itineraries', zh: '我的行程' },
|
||||
chatMenu: { vi: 'Trò chuyện', en: 'Chat', zh: '聊天' },
|
||||
friendsMenu: { vi: 'Danh sách bạn bè', en: 'Friends List', zh: '好友列表' },
|
||||
muteNotifications: { vi: 'Tắt thông báo đẩy', en: 'Mute Notifications', zh: '关闭推送通知' },
|
||||
unmuteNotifications: { vi: 'Bật thông báo đẩy', en: 'Unmute Notifications', zh: '开启推送通知' },
|
||||
enterSecretKey: { vi: 'Nhập Admin Secret Key để mở khóa', en: 'Enter Admin Secret Key to unlock', zh: '输入管理员密钥解锁' },
|
||||
invalidSecretKey: { vi: 'Mã Secret Key không hợp lệ', en: 'Invalid Secret Key', zh: '密钥无效' },
|
||||
languageSelect: { vi: 'Ngôn ngữ', en: 'Language', zh: '语言' },
|
||||
themeSelect: { vi: 'Giao diện', en: 'Theme', zh: '主题' },
|
||||
themeLight: { vi: 'Sáng', en: 'Light', zh: '浅色' },
|
||||
themeDark: { vi: 'Tối', en: 'Dark', zh: '深色' },
|
||||
themeSystem: { vi: 'Hệ thống', en: 'System', zh: '跟随系统' },
|
||||
|
||||
// Emergency Share Journey Page
|
||||
emergencyContacts: { vi: 'Liên hệ khẩn cấp', en: 'Emergency Contacts', zh: '紧急联系人' },
|
||||
tourOwner: { vi: 'Người tạo Tour (Owner)', en: 'Tour Owner', zh: '发起人' },
|
||||
tourManager: { vi: 'Người quản lý (Manager)', en: 'Tour Manager', zh: '管理员' },
|
||||
phoneNumber: { vi: 'Số điện thoại', en: 'Phone Number', zh: '电话号码' },
|
||||
emergencyJourney: { vi: 'Hành trình Cứu hộ Khẩn cấp', en: 'Emergency Rescue Journey', zh: '紧急救援行程' },
|
||||
noPhone: { vi: 'Không có số điện thoại', en: 'No phone number', zh: '暂无电话' },
|
||||
noStops: { vi: 'Chưa có điểm dừng nào', en: 'No stops declared', zh: '暂无停留点' },
|
||||
viewMap: { vi: 'Bản đồ', en: 'Map', zh: '地图' },
|
||||
viewTimeline: { vi: 'Lịch trình', en: 'Itinerary', zh: '行程表' },
|
||||
sharedJourneyTitle: { vi: 'Hành trình chia sẻ khẩn cấp', en: 'Emergency Shared Journey', zh: '紧急分享行程' },
|
||||
linkExpired: { vi: 'Liên kết không tồn tại hoặc đã bị vô hiệu hóa.', en: 'Link does not exist or has been disabled.', zh: '链接不存在或已被禁用。' }
|
||||
};
|
||||
|
||||
export const useTranslation = () => {
|
||||
const [lang, setLang] = useState<Language>(() => {
|
||||
return (localStorage.getItem('language') as Language) || 'vi';
|
||||
});
|
||||
|
||||
const changeLanguage = (newLang: Language) => {
|
||||
localStorage.setItem('language', newLang);
|
||||
setLang(newLang);
|
||||
// Dispatch custom event to sync across components
|
||||
window.dispatchEvent(new Event('languageChange'));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleLangChange = () => {
|
||||
setLang((localStorage.getItem('language') as Language) || 'vi');
|
||||
};
|
||||
window.addEventListener('languageChange', handleLangChange);
|
||||
return () => window.removeEventListener('languageChange', handleLangChange);
|
||||
}, []);
|
||||
|
||||
const t = (key: string): string => {
|
||||
if (!translations[key]) {
|
||||
return key;
|
||||
}
|
||||
return translations[key][lang];
|
||||
};
|
||||
|
||||
return { t, lang, changeLanguage };
|
||||
};
|
||||
Reference in New Issue
Block a user