From 9795776edf0cd73b132a6297b621cf7fc3a481cb Mon Sep 17 00:00:00 2001 From: blinko <blinkospace@gmail.com> Date: Fri, 27 Dec 2024 13:50:18 +0800 Subject: [PATCH] feat: enhance note sharing functionality and update translations #368 #356 #362 - Added new fields to the notes model for sharing: shareEncryptedUrl, shareExpiryDate, shareMaxView, and shareViewCount. - Implemented share note feature in the backend, allowing notes to be shared with optional password protection and expiration. - Updated frontend components to support sharing functionality, including a share dialog and password verification. - Enhanced user experience with new translations for sharing-related messages across multiple languages. - Removed fallback-development.js as it is no longer needed. --- .../20241226141834_0_30_7/migration.sql | 5 + prisma/schema.prisma | 4 + public/fallback-7ih1VBpDWHKhJ-2-n8ZvF.js | 1 + public/fallback-development.js | 28 -- public/locales/ar/translation.json | 20 +- public/locales/de/translation.json | 20 +- public/locales/en/translation.json | 20 +- public/locales/es/translation.json | 20 +- public/locales/fr/translation.json | 20 +- public/locales/ja/translation.json | 20 +- public/locales/ko/translation.json | 20 +- public/locales/pt/translation.json | 20 +- public/locales/ru/translation.json | 20 +- public/locales/tr/translation.json | 20 +- public/locales/zh-TW/translation.json | 20 +- public/locales/zh/translation.json | 20 +- src/components/BlinkoRightClickMenu/index.tsx | 24 +- src/components/BlinkoShareDialog/index.tsx | 265 ++++++++++++++++++ src/lib/prismaZodType.ts | 4 + src/pages/share/[id].tsx | 114 +++++++- src/server/routers/note.ts | 118 +++++++- src/server/routers/public.ts | 1 - src/store/blinkoStore.tsx | 9 + 23 files changed, 745 insertions(+), 68 deletions(-) create mode 100644 prisma/migrations/20241226141834_0_30_7/migration.sql create mode 100644 public/fallback-7ih1VBpDWHKhJ-2-n8ZvF.js delete mode 100644 public/fallback-development.js create mode 100644 src/components/BlinkoShareDialog/index.tsx diff --git a/prisma/migrations/20241226141834_0_30_7/migration.sql b/prisma/migrations/20241226141834_0_30_7/migration.sql new file mode 100644 index 00000000..15880bf0 --- /dev/null +++ b/prisma/migrations/20241226141834_0_30_7/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "notes" ADD COLUMN "shareEncryptedUrl" VARCHAR, +ADD COLUMN "shareExpiryDate" TIMESTAMPTZ(6), +ADD COLUMN "shareMaxView" INTEGER DEFAULT 0, +ADD COLUMN "shareViewCount" INTEGER DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3c0880b6..fdd95b1c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -63,6 +63,10 @@ model notes { isTop Boolean @default(false) isReviewed Boolean @default(false) sharePassword String @default("") @db.VarChar + shareEncryptedUrl String? @db.VarChar + shareExpiryDate DateTime? @db.Timestamptz(6) + shareMaxView Int? @default(0) + shareViewCount Int? @default(0) metadata Json? @db.Json accountId Int? createdAt DateTime @default(now()) @db.Timestamptz(6) diff --git a/public/fallback-7ih1VBpDWHKhJ-2-n8ZvF.js b/public/fallback-7ih1VBpDWHKhJ-2-n8ZvF.js new file mode 100644 index 00000000..14deb87a --- /dev/null +++ b/public/fallback-7ih1VBpDWHKhJ-2-n8ZvF.js @@ -0,0 +1 @@ +(()=>{"use strict";self.fallback=async e=>"document"===e.destination?caches.match("/offline",{ignoreSearch:!0}):Response.error()})(); \ No newline at end of file diff --git a/public/fallback-development.js b/public/fallback-development.js deleted file mode 100644 index 4ab7d9a6..00000000 --- a/public/fallback-development.js +++ /dev/null @@ -1,28 +0,0 @@ -/******/ (() => { // webpackBootstrap -/******/ "use strict"; -var __webpack_exports__ = {}; - - -self.fallback = async request => { - // https://developer.mozilla.org/en-US/docs/Web/API/RequestDestination - switch (request.destination) { - case 'document': - if (true) return caches.match("/offline", { - ignoreSearch: true - }); - case 'image': - if (false) {} - case 'audio': - if (false) {} - case 'video': - if (false) {} - case 'font': - if (false) {} - case '': - if (false) {} - default: - return Response.error(); - } -}; -/******/ })() -; \ No newline at end of file diff --git a/public/locales/ar/translation.json b/public/locales/ar/translation.json index ad785d93..e1957b8a 100644 --- a/public/locales/ar/translation.json +++ b/public/locales/ar/translation.json @@ -398,5 +398,23 @@ "offline": "غير متصل", "close-background-animation": "إغلاق الخلفية المتحركة", "custom-bg-tip": "انتقل إلى https://www.shadergradient.co/ لإنشاء خلفية التدرج الخاصة بك", - "custom-background-url": "الخلفية المخصصة" + "custom-background-url": "الخلفية المخصصة", + "share": "مشاركة", + "need-password-to-access": "مطلوب كلمة مرور للوصول", + "password-error": "خطأ في كلمة المرور", + "cancel-share": "إلغاء المشاركة", + "create-share": "إنشاء مشاركة", + "share-link": "حصة الرابط", + "set-access-password": "قم بتعيين كلمة مرور الوصول", + "protect-your-shared-content": "حافظ على محتواك المشترك", + "access-password": "كلمة مرور الوصول", + "select-date": "حدد التاريخ", + "expiry-time": "وقت انتهاء الصلاحية", + "select-expiry-time": "اختر وقت انتهاء الصلاحية", + "permanent-valid": "صالح دائما", + "7days-expiry": "انتهاء صلاحية بعد 7 أيام", + "custom-expiry": "انتهاء مخصص", + "30days-expiry": "انتهاء صلاحية خلال 30 يومًا", + "share-link-expired": "انتهت صلاحية الرابط المشاركة", + "share-link-expired-desc": "لقد انتهت صلاحية هذا المشاركة، يرجى الاتصال بالمسؤول لإعادة مشاركتها!" } diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json index effdfcff..f058afc0 100644 --- a/public/locales/de/translation.json +++ b/public/locales/de/translation.json @@ -384,5 +384,23 @@ "offline": "Offline", "close-background-animation": "Schließen der Hintergrundanimation", "custom-bg-tip": "Gehe zu https://www.shadergradient.co/, um deinen eigenen Verlaufshintergrund zu erstellen.", - "custom-background-url": "Benutzerdefinierten Hintergrund" + "custom-background-url": "Benutzerdefinierten Hintergrund", + "share": "Teilen", + "need-password-to-access": "Passwortzugriff erforderlich", + "password-error": "Passwortfehler", + "cancel-share": "Stornieren Freigeben", + "create-share": "Erstellen Teilen", + "share-link": "Freigabelink", + "set-access-password": "Legen Sie das Zugriffspasswort fest", + "protect-your-shared-content": "Schütze deine gemeinsamen Inhalte.", + "access-password": "Zugriffspasswort", + "select-date": "Wählen Sie ein Datum aus", + "expiry-time": "Verfallszeit", + "select-expiry-time": "Wählen Sie das Ablaufdatum aus.", + "permanent-valid": "Dauerhaft gültig", + "7days-expiry": "7 Tage Ablauf", + "custom-expiry": "Benutzerdefiniertes Ablaufdatum", + "30days-expiry": "30 Tage Ablauf", + "share-link-expired": "Freigabelink abgelaufen", + "share-link-expired-desc": "Dieser Link ist abgelaufen. Bitte wenden Sie sich an den Administrator, um ihn erneut freizugeben!" } diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 1bae3069..60a66e9b 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -401,5 +401,23 @@ "offline": "Offline", "close-background-animation": "Close Background Animation", "custom-background-url": "Custom Background", - "custom-bg-tip": "Go to https://www.shadergradient.co/ to create your own gradient background" + "custom-bg-tip": "Go to https://www.shadergradient.co/ to create your own gradient background", + "share": "Share", + "need-password-to-access": "Password access required", + "password-error": "Password error", + "create-share": "Create Share", + "cancel-share": "Cancel Share", + "share-link": "Share Link", + "set-access-password": "Set access password", + "protect-your-shared-content": "Protect your shared content", + "access-password": "Access password", + "select-date": "Select date", + "expiry-time": "Expiry Time", + "select-expiry-time": "Select Expiry Time", + "permanent-valid": "Permanent Valid", + "7days-expiry": "7 Days Expiry", + "30days-expiry": "30 Days Expiry", + "custom-expiry": "Custom Expiry", + "share-link-expired": "Share Link Expired", + "share-link-expired-desc": "This share has expired please contact the administrator to re-share!" } diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json index 4d12ddeb..b34af581 100644 --- a/public/locales/es/translation.json +++ b/public/locales/es/translation.json @@ -376,5 +376,23 @@ "offline": "Sin conexión", "close-background-animation": "Cerrar Animación de Fondo", "custom-bg-tip": "Ve a https://www.shadergradient.co/ para crear tu propio fondo degradado.", - "custom-background-url": "Fondo personalizado" + "custom-background-url": "Fondo personalizado", + "share": "Compartir", + "need-password-to-access": "Se requiere contraseña para acceder.", + "password-error": "Error de contraseña", + "cancel-share": "Cancelar Compartir", + "create-share": "Crear Compartir", + "share-link": "Compartir enlace", + "set-access-password": "Establecer contraseña de acceso", + "protect-your-shared-content": "Proteja su contenido compartido", + "access-password": "Contraseña de acceso", + "select-date": "Seleccionar fecha", + "expiry-time": "Tiempo de expiración", + "select-expiry-time": "Seleccionar tiempo de vencimiento", + "permanent-valid": "Válido permanente", + "7days-expiry": "Caducidad de 7 días", + "custom-expiry": "Caducidad personalizada", + "30days-expiry": "Caducidad de 30 días.", + "share-link-expired": "Enlace compartido caducado", + "share-link-expired-desc": "¡Esta compartición ha caducado, por favor contacta al administrador para volver a compartir!" } diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index 93b875d6..12a5da79 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -373,5 +373,23 @@ "offline": "Hors ligne", "close-background-animation": "Animation d'arrière-plan proche", "custom-bg-tip": "Allez sur https://www.shadergradient.co/ pour créer votre propre arrière-plan dégradé.", - "custom-background-url": "Arrière-plan personnalisé" + "custom-background-url": "Arrière-plan personnalisé", + "share": "Partager", + "need-password-to-access": "Accès par mot de passe requis", + "password-error": "Erreur de mot de passe", + "cancel-share": "Annuler le partage", + "create-share": "Créer Partager", + "share-link": "Partager le lien", + "set-access-password": "Définir le mot de passe d'accès", + "protect-your-shared-content": "Protégez votre contenu partagé.", + "access-password": "Mot de passe d'accès", + "select-date": "Sélectionner la date", + "expiry-time": "Heure d'expiration", + "select-expiry-time": "Sélectionnez l'heure d'expiration", + "permanent-valid": "Valide en permanence", + "7days-expiry": "Expiration de 7 jours", + "custom-expiry": "Expiration personnalisée", + "30days-expiry": "30 jours d'expiration", + "share-link-expired": "Lien partagé expiré", + "share-link-expired-desc": "Cette part a expiré, veuillez contacter l'administrateur pour la re-partager !" } diff --git a/public/locales/ja/translation.json b/public/locales/ja/translation.json index 8d690120..6e29a308 100644 --- a/public/locales/ja/translation.json +++ b/public/locales/ja/translation.json @@ -358,5 +358,23 @@ "offline": "オフライン", "close-background-animation": "背景アニメーションを閉じる", "custom-bg-tip": "https://www.shadergradient.co/ にアクセスして、独自のグラデーション背景を作成してください。", - "custom-background-url": "カスタム背景" + "custom-background-url": "カスタム背景", + "share": "共有", + "need-password-to-access": "パスワードアクセスが必要です", + "password-error": "パスワードエラー", + "cancel-share": "共有をキャンセル", + "create-share": "共有を作成する", + "share-link": "共有リンク", + "set-access-password": "アクセスパスワードを設定します", + "protect-your-shared-content": "共有コンテンツを保護します。", + "access-password": "アクセスパスワード", + "select-date": "日付を選択してください。", + "expiry-time": "有効期限", + "select-expiry-time": "有効期限を選択します", + "permanent-valid": "永久有効", + "7days-expiry": "有効期限7日間", + "custom-expiry": "カスタム有効期限", + "30days-expiry": "30日有効期限", + "share-link-expired": "共有リンクの期限切れ", + "share-link-expired-desc": "この共有は期限切れです。再共有するには管理者に連絡してください!" } diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json index 05531f29..f467194c 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -381,5 +381,23 @@ "offline": "오프라인.", "close-background-animation": "배경 애니메이션 닫기", "custom-bg-tip": "https://www.shadergradient.co/ 에 방문하여 자체 그라데이션 배경을 만드세요.", - "custom-background-url": "사용자 정의 배경" + "custom-background-url": "사용자 정의 배경", + "share": "공유하기", + "need-password-to-access": "비밀번호로 접근이 필요합니다.", + "password-error": "비밀번호 오류", + "cancel-share": "공유 취소", + "create-share": "공유 만들기", + "share-link": "공유 링크", + "set-access-password": "액세스 암호 설정", + "protect-your-shared-content": "공유된 콘텐츠를 보호하세요.", + "access-password": "액세스 비밀번호", + "select-date": "날짜 선택", + "expiry-time": "만료 시간", + "select-expiry-time": "만료 시간 선택", + "permanent-valid": "영구적 유효", + "7days-expiry": "7일 만료", + "custom-expiry": "사용자 정의 만료", + "30days-expiry": "30일 만료", + "share-link-expired": "공유 링크가 만료되었습니다.", + "share-link-expired-desc": "이 공유가 만료되었습니다. 다시 공유하려면 관리자에게 문의하세요!" } diff --git a/public/locales/pt/translation.json b/public/locales/pt/translation.json index 3dacf030..31aca300 100644 --- a/public/locales/pt/translation.json +++ b/public/locales/pt/translation.json @@ -309,5 +309,23 @@ "offline": "Offline", "close-background-animation": "Fechar Animação de Fundo", "custom-bg-tip": "Acesse https://www.shadergradient.co/ para criar seu próprio fundo de gradiente.", - "custom-background-url": "Fundo Personalizado" + "custom-background-url": "Fundo Personalizado", + "share": "Compartilhar", + "need-password-to-access": "Acesso com senha é necessário", + "password-error": "Erro de senha", + "cancel-share": "Cancelar Compartilhar", + "create-share": "Criar Compartilhar", + "share-link": "Compartilhar Link", + "set-access-password": "Definir senha de acesso", + "protect-your-shared-content": "Proteja seu conteúdo compartilhado", + "access-password": "Senha de acesso", + "select-date": "Selecionar data", + "expiry-time": "Tempo de validade", + "select-expiry-time": "Selecionar Tempo de Expiração", + "permanent-valid": "Válido permanentemente.", + "7days-expiry": "Validade de 7 dias", + "custom-expiry": "Validade personalizada", + "30days-expiry": "30 Dias de Validade", + "share-link-expired": "O link compartilhado expirou", + "share-link-expired-desc": "Esta partilha expirou, por favor entre em contato com o administrador para recriar a partilha!" } diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index 1c3dde1f..d1d69aed 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -317,5 +317,23 @@ "offline": "Офлайн", "close-background-animation": "Закрыть фоновую анимацию", "custom-bg-tip": "Перейдите на https://www.shadergradient.co/, чтобы создать свой собственный градиентный фон.", - "custom-background-url": "Пользовательский фон" + "custom-background-url": "Пользовательский фон", + "share": "Поделиться", + "need-password-to-access": "Требуется пароль для доступа", + "password-error": "Ошибка пароля", + "cancel-share": "Отменить публикацию", + "create-share": "Создать Совместно", + "share-link": "Поделиться ссылкой", + "set-access-password": "Установите пароль доступа", + "protect-your-shared-content": "Защищайте ваш общий контент", + "access-password": "Пароль доступа", + "select-date": "Выберите дату", + "expiry-time": "Время окончания", + "select-expiry-time": "Выберите время истечения.", + "permanent-valid": "Постоянно действительный", + "7days-expiry": "Истекает через 7 дней", + "custom-expiry": "Пользовательский срок действия", + "30days-expiry": "Срок действия 30 дней", + "share-link-expired": "Ссылка для обмена устарела", + "share-link-expired-desc": "Эта доля истекла, пожалуйста, свяжитесь с администратором для повторного обмена." } diff --git a/public/locales/tr/translation.json b/public/locales/tr/translation.json index 2cfbf357..fb220a6c 100644 --- a/public/locales/tr/translation.json +++ b/public/locales/tr/translation.json @@ -327,5 +327,23 @@ "offline": "Çevrimdışı", "close-background-animation": "Arka Plan Animasyonunu Kapat", "custom-bg-tip": "Kendi gradyan arka planınızı oluşturmak için https://www.shadergradient.co/ adresine gidin.", - "custom-background-url": "Özel Arka Plan" + "custom-background-url": "Özel Arka Plan", + "share": "Paylaşmak", + "need-password-to-access": "Şifre erişimi gereklidir", + "password-error": "Şifre hatası", + "cancel-share": "Paylaşımı İptal Et", + "create-share": "Paylaşım Oluştur", + "share-link": "Paylaşma Bağlantısı", + "set-access-password": "Erişim şifresi ayarla", + "protect-your-shared-content": "Paylaşılan içeriğinizi koruyun", + "access-password": "Şifreye erişim", + "select-date": "Tarih seçiniz", + "expiry-time": "Son Kullanma Tarihi", + "select-expiry-time": "Son Kullanma Tarihini Seç", + "permanent-valid": "Sürekli Geçerli", + "7days-expiry": "7 Gün Son Kullanım Tarihi", + "custom-expiry": "Özel Son Kullanma Tarihi", + "30days-expiry": "30 Gün Son Kullanma Tarihi", + "share-link-expired": "Paylaşım Bağlantısı Süresi Doldu", + "share-link-expired-desc": "Bu paylaşımın süresi doldu, lütfen tekrar paylaşım yapabilmek için yöneticiyle iletişime geçin!" } diff --git a/public/locales/zh-TW/translation.json b/public/locales/zh-TW/translation.json index d8a5163e..d9e7f464 100644 --- a/public/locales/zh-TW/translation.json +++ b/public/locales/zh-TW/translation.json @@ -414,5 +414,23 @@ "offline": "離線", "close-background-animation": "關閉背景動畫", "custom-bg-tip": "前往https://www.shadergradient.co/ 創建您自己的漸層背景。", - "custom-background-url": "自訂背景" + "custom-background-url": "自訂背景", + "share": "分享", + "need-password-to-access": "需要密碼訪問", + "password-error": "密碼錯誤", + "cancel-share": "取消分享", + "create-share": "創建分享", + "share-link": "分享連結", + "set-access-password": "設置存取密碼", + "protect-your-shared-content": "保護您的共享內容", + "access-password": "存取密碼", + "select-date": "選擇日期", + "expiry-time": "到期時間", + "select-expiry-time": "選擇到期時間", + "permanent-valid": "永久有效", + "7days-expiry": "7天到期", + "custom-expiry": "自訂到期", + "30days-expiry": "30天到期", + "share-link-expired": "分享連結已過期", + "share-link-expired-desc": "此分享已過期,請聯繫管理員重新分享!" } diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json index 3b288f3c..8c89469b 100644 --- a/public/locales/zh/translation.json +++ b/public/locales/zh/translation.json @@ -404,5 +404,23 @@ "offline": "离线", "close-background-animation": "关闭背景动画", "custom-bg-tip": "访问 https://www.shadergradient.co/ 创建您自己的渐变背景", - "custom-background-url": "自定义背景" + "custom-background-url": "自定义背景", + "share": "分享", + "need-password-to-access": "密码访问已被要求", + "password-error": "密码错误", + "create-share": "创建分享", + "cancel-share": "取消分享", + "share-link": "分享链接", + "set-access-password": "设置访问密码", + "access-password": "访问密码", + "protect-your-shared-content": "保护您分享的内容", + "select-date": "选择日期", + "expiry-time": "过期时间", + "select-expiry-time": "选择到期时间", + "permanent-valid": "永久有效", + "7days-expiry": "7天到期", + "custom-expiry": "自定义到期", + "30days-expiry": "30天有效期", + "share-link-expired": "分享链接已过期", + "share-link-expired-desc": "这份共享已过期,请联系管理员重新共享!" } diff --git a/src/components/BlinkoRightClickMenu/index.tsx b/src/components/BlinkoRightClickMenu/index.tsx index 75a08c1a..2a360efc 100644 --- a/src/components/BlinkoRightClickMenu/index.tsx +++ b/src/components/BlinkoRightClickMenu/index.tsx @@ -17,6 +17,7 @@ import { AiStore } from "@/store/aiStore"; import { FocusEditorFixMobile } from "../Common/Editor/editorUtils"; import { parseAbsoluteToLocal } from "@internationalized/date"; import i18n from "@/lib/i18n"; +import { BlinkoShareDialog } from "../BlinkoShareDialog"; export const ShowEditTimeModel = () => { const blinko = RootStore.Get(BlinkoStore) @@ -107,10 +108,23 @@ const handleTop = () => { const handlePublic = () => { const blinko = RootStore.Get(BlinkoStore) - blinko.upsertNote.call({ - id: blinko.curSelectedNote?.id, - isShare: !blinko.curSelectedNote?.isShare + RootStore.Get(DialogStore).setData({ + size: 'md' as any, + isOpen: true, + title: i18n.t('share'), + isDismissable: false, + content: <BlinkoShareDialog defaultSettings={{ + shareUrl: blinko.curSelectedNote?.shareEncryptedUrl ? window.location.origin + '/share/' + blinko.curSelectedNote?.shareEncryptedUrl : undefined, + expiryDate: blinko.curSelectedNote?.shareExpiryDate ?? undefined, + password: blinko.curSelectedNote?.sharePassword ?? '', + isShare: blinko.curSelectedNote?.isShare + }} /> }) + + // blinko.upsertNote.call({ + // id: blinko.curSelectedNote?.id, + // isShare: !blinko.curSelectedNote?.isShare + // }) } const handleArchived = () => { @@ -203,7 +217,7 @@ export const PublicItem = observer(() => { const blinko = RootStore.Get(BlinkoStore) return <div className="flex items-start gap-2"> <Icon icon="ic:outline-share" width="20" height="20" /> - <div>{blinko.curSelectedNote?.isShare ? t('unset-as-public') : t('set-as-public')}</div> + <div>{t('share')}</div> </div> }) @@ -332,7 +346,7 @@ export const LeftCickMenu = observer(({ onTrigger, className }: { onTrigger: () <DropdownItem key="MutiSelectItem" onPress={() => { handleMultiSelect() }}><MutiSelectItem /></DropdownItem> - <DropdownItem key="EditTimeItem" onPress={() => ShowEditTimeModel()}> <EditTimeItem /></DropdownItem> + <DropdownItem key="EditTimeItem" onPress={() => ShowEditTimeModel()}> <EditTimeItem /></DropdownItem> <DropdownItem key="ConvertItem" onPress={ConvertItemFunction}> <ConvertItem /></DropdownItem> <DropdownItem key="TopItem" onPress={handleTop}> <TopItem /> </DropdownItem> <DropdownItem key="ShareItem" onPress={handlePublic}> <PublicItem /> </DropdownItem> diff --git a/src/components/BlinkoShareDialog/index.tsx b/src/components/BlinkoShareDialog/index.tsx new file mode 100644 index 00000000..4deaa40d --- /dev/null +++ b/src/components/BlinkoShareDialog/index.tsx @@ -0,0 +1,265 @@ +import { observer } from "mobx-react-lite"; +import { Button, Card, Switch, Input, Dropdown, DropdownTrigger, DropdownMenu, DropdownItem, Popover, PopoverTrigger, PopoverContent, InputOtp, Divider } from "@nextui-org/react"; +import { today, getLocalTimeZone, parseDate } from "@internationalized/date"; +import dayjs from "@/lib/dayjs"; +import { useState, useMemo } from "react"; +import { Icon } from "@iconify/react"; +import { Calendar } from "@nextui-org/react"; +import { motion, AnimatePresence } from "framer-motion"; +import { RootStore } from "@/store"; +import { BlinkoStore } from "@/store/blinkoStore"; +import { useTranslation } from "react-i18next"; +import { DialogStore } from "@/store/module/Dialog"; + +interface ShareDialogProps { + defaultSettings: ShareSettings; + shareUrl?: string; +} + +export interface ShareSettings { + expiryDate?: Date; + password?: string; + shareUrl?: string; + isShare?: boolean; +} + +const expiryOptions = [ + { key: "never", label: ("permanent-valid") }, + { key: "7days", label: ("7days-expiry") }, + { key: "30days", label: ("30days-expiry") }, + { key: "custom", label: ("custom-expiry") }, +]; + +const generateRandomPassword = () => { + return Math.floor(100000 + Math.random() * 900000).toString(); +}; + +export const BlinkoShareDialog = observer(({ defaultSettings }: ShareDialogProps) => { + const { t } = useTranslation() + const [settings, setSettings] = useState<ShareSettings>(() => { + const initialPassword = defaultSettings.shareUrl ? defaultSettings.password : generateRandomPassword(); + return { + ...defaultSettings, + password: initialPassword + }; + }); + const [expiryType, setExpiryType] = useState<string>(() => { + return defaultSettings.expiryDate ? "custom" : "never"; + }); + const [isPublic, setIsPublic] = useState<boolean>(defaultSettings.password ? false : true); + const [isShare, setIsShare] = useState<boolean>(defaultSettings.isShare ?? false); + + const [shareUrl, setShareUrl] = useState<string>(defaultSettings?.shareUrl ?? ''); + const [isCalendarOpen, setIsCalendarOpen] = useState(false); + + const selectedExpiryValue = useMemo(() => { + if (expiryType === "never") return t("permanent-valid"); + if (settings.expiryDate) { + return dayjs(settings.expiryDate).format('YYYY-MM-DD'); + } + return t("select-expiry-time"); + }, [expiryType, settings.expiryDate]); + + const handleExpiryChange = (type: string) => { + setExpiryType(type); + if (type === "never") { + setSettings({ ...settings, expiryDate: undefined }); + } else if (type === "7days") { + setSettings({ + ...settings, + expiryDate: dayjs().add(7, 'days').toDate() + }); + } else if (type === "30days") { + setSettings({ + ...settings, + expiryDate: dayjs().add(30, 'days').toDate() + }); + } else { + setSettings({ + ...settings, + expiryDate: settings.expiryDate || dayjs().add(1, 'day').toDate() + }); + setIsCalendarOpen(true); + } + }; + + return ( + <Card shadow="none" className="flex flex-col gap-8 p-2 -mt-4"> + <div className="flex flex-col gap-2"> + <div className="flex items-center gap-2 "> + <span className="text-default-700 font-medium">{t("expiry-time")}</span> + <AnimatePresence mode="wait"> + {settings.expiryDate && ( + <motion.div + className="ml-auto bg-[#FEF4D5] text-sm text-[#F68C06] px-2 py-1 rounded-full flex items-center gap-2" + initial={{ x: -20, opacity: 0 }} + animate={{ x: 0, opacity: 1 }} + exit={{ x: -20, opacity: 0 }} + transition={{ duration: 0.3 }} + > + <Icon icon="lets-icons:clock" className="text-[#F68C06]" width="20" height="20" /> + {dayjs(settings.expiryDate).fromNow()} + </motion.div> + )} + </AnimatePresence> + </div> + + <div className="flex gap-2 mt-2"> + <Dropdown> + <DropdownTrigger> + <Button + variant="bordered" + size="lg" + className="flex-1 justify-start " + startContent={<Icon icon="solar:calendar-bold" className="text-default-500" width="20" height="20" />} + > + {selectedExpiryValue} + </Button> + </DropdownTrigger> + <DropdownMenu + onAction={(key) => handleExpiryChange(key as string)} + selectedKeys={[expiryType]} + > + {expiryOptions.map((option) => ( + <DropdownItem key={option.key}> + {t(option.label)} + </DropdownItem> + ))} + </DropdownMenu> + </Dropdown> + + {expiryType === "custom" && ( + <Popover + isOpen={isCalendarOpen} + onOpenChange={setIsCalendarOpen} + placement="bottom-end" + > + <PopoverTrigger> + <Button + variant="bordered" + size="lg" + startContent={<Icon icon="solar:calendar-mark-bold" className="text-default-500" width="20" height="20" />} + > + {settings.expiryDate ? dayjs(settings.expiryDate).format('YYYY-MM-DD') : t("select-date")} + </Button> + </PopoverTrigger> + <PopoverContent> + <div className="p-1 bg-content1"> + <Calendar + minValue={today(getLocalTimeZone())} + value={settings.expiryDate ? parseDate(dayjs(settings.expiryDate).format('YYYY-MM-DD')) : today(getLocalTimeZone()).add({ days: 1 })} + onChange={(date) => { + if (date) { + setSettings({ + ...settings, + expiryDate: new Date(date.toString()) + }); + setIsCalendarOpen(false); + } + }} + /> + </div> + </PopoverContent> + </Popover> + )} + </div> + </div> + + <div className="flex flex-col gap-2"> + <div className="flex items-center gap-2"> + <span className="text-default-700 font-medium">{t("access-password")}</span> + <span className="text-default-400 text-sm">{t("protect-your-shared-content")}</span> + <Switch + className="ml-auto" + size="sm" + isSelected={!isPublic} + onValueChange={(checked) => { + setIsPublic(!checked); + if (checked) { + setSettings({ ...settings, password: generateRandomPassword() }); + } + }} + /> + </div> + <div className="flex w-full justify-center items-center"> + {!isPublic && ( + <InputOtp + length={6} + placeholder={t("set-access-password")} + value={settings.password} + onValueChange={(value) => setSettings({ ...settings, password: value })} + /> + )} + </div> + </div> + + <Divider className="my-2" /> + + + <AnimatePresence mode="wait"> + { + shareUrl && ( + <motion.div + className="flex flex-col gap-2" + initial={{ y: -20, opacity: 0 }} + animate={{ y: 0, opacity: 1 }} + exit={{ y: -20, opacity: 0 }} + transition={{ duration: 0.2 }} + > + <div className="flex items-center gap-2 mb-2"> + <span className="text-default-700 font-medium">{t("share-link")}</span> + </div> + <div className="flex gap-2"> + <Input + variant="bordered" + value={shareUrl} + readOnly + classNames={{ + input: "bg-default-50", + inputWrapper: "bg-default-50" + }} + /> + <Button + isIconOnly + variant="flat" + onPress={() => navigator.clipboard.writeText(shareUrl)} + > + <Icon icon="solar:copy-bold" width="20" height="20" /> + </Button> + </div> + </motion.div> + ) + } + </AnimatePresence> + + <div className="w-full flex items-end gap-4"> + { + isShare && ( + <Button variant="flat" className="w-full" onPress={() => { + RootStore.Get(BlinkoStore).shareNote.call({ + id: RootStore.Get(BlinkoStore).curSelectedNote!.id!, + isCancel: true, + }) + setIsShare(false) + RootStore.Get(DialogStore).close() + }}> + {t("cancel-share")} + </Button> + ) + } + <Button color="primary" className="w-full" onPress={async () => { + const res = await RootStore.Get(BlinkoStore).shareNote.call({ + id: RootStore.Get(BlinkoStore).curSelectedNote!.id!, + isCancel: false, + password: isPublic ? "" : settings.password, + expireAt: settings.expiryDate + }) + setShareUrl(window.location.origin + '/share/' + (res?.shareEncryptedUrl ?? '') + (isPublic ? '' : '?password=' + (settings.password ?? ''))) + setIsShare(true) + }}> + {t("create-share")} + </Button> + </div> + </Card> + ); +}); diff --git a/src/lib/prismaZodType.ts b/src/lib/prismaZodType.ts index 5632f637..081d1b6d 100644 --- a/src/lib/prismaZodType.ts +++ b/src/lib/prismaZodType.ts @@ -68,6 +68,10 @@ export const notesSchema = z.object({ isTop: z.boolean(), isReviewed: z.boolean(), sharePassword: z.string(), + shareEncryptedUrl: z.string().nullable().optional(), + shareExpiryDate: z.date().nullable().optional(), + shareMaxView: z.number().nullable().optional(), + shareViewCount: z.number().nullable().optional(), metadata: z.any(), accountId: z.union([z.number().int(), z.null()]), createdAt: z.coerce.date(), diff --git a/src/pages/share/[id].tsx b/src/pages/share/[id].tsx index 8ebb21ef..cc500e72 100644 --- a/src/pages/share/[id].tsx +++ b/src/pages/share/[id].tsx @@ -5,30 +5,59 @@ import { RootStore } from "@/store"; import { PromiseState } from "@/store/standard/PromiseState"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useMediaQuery } from "usehooks-ts"; import VanillaTilt from 'vanilla-tilt'; import dynamic from "next/dynamic"; +import { Card, InputOtp, Button } from "@nextui-org/react"; +import { Icon } from "@iconify/react"; +import { useTranslation } from "react-i18next"; const GradientBackground = dynamic( () => import('@/components/Common/GradientBackground').then((mod) => mod.GradientBackground), { ssr: false } ); -const Page = observer(() => { + +const Page: React.FC = observer(() => { const isPc = useMediaQuery('(min-width: 768px)') const router = useRouter() + const { t } = useTranslation() + const [password, setPassword] = useState(""); + const [error, setError] = useState(false); + const [hasPassword, setHasPassword] = useState(false); + const [isExpired, setIsExpired] = useState(false); + const store = RootStore.Local(() => ({ shareNote: new PromiseState({ - function: async (id) => { - const notes = await api.notes.publicDetail.mutate({ id }) - return notes + function: async (shareEncryptedUrl: string, password?: string) => { + try { + const notes = await api.notes.publicDetail.mutate({ shareEncryptedUrl, password }) + if (notes.error === 'expired') { + setIsExpired(true); + return null; + } + if (notes.hasPassword && !password) { + setHasPassword(true); + return null; + } + return notes.data; + } catch (e) { + setError(true); + return null; + } } }) })) useEffect(() => { if (!router.query.id) return - store.shareNote.call(Number(router.query.id)) + const urlPassword = router.query.password as string; + if (urlPassword) { + setPassword(urlPassword); + store.shareNote.call(router.query.id as string, urlPassword); + } else { + store.shareNote.call(router.query.id as string); + } }, [router.isReady]) useEffect(() => { @@ -42,16 +71,71 @@ const Page = observer(() => { }); }, [store.shareNote?.value]); - return <GradientBackground> - <div className='p-4 h-[100vh] w-full flex justify-center items-center' > - { - store.shareNote?.value && - <div className="tilt-card glass-effect max-h-[90vh] overflow-y-scroll w-[95%] md:min-w-[30%] md:max-w-[50%] rounded-xl shadow-[1px_0_25px_11px_rgba(98,0,114,0.17)]"> - <BlinkoCard blinkoItem={store.shareNote?.value} isShareMode glassEffect /> + const handleVerify = () => { + setError(false); + store.shareNote.call(router.query.id as string, password); + }; + + if (isExpired) { + return ( + <GradientBackground> + <div className='p-4 h-[100vh] w-full flex justify-center items-center'> + <Card className="p-6 flex flex-col gap-4 items-center glass-effect"> + <div className="flex items-center gap-2"> + <Icon icon="solar:clock-circle-bold" className="text-2xl text-danger" /> + <span className="text-xl font-medium text-danger">{t("share-link-expired")}</span> + </div> + <p className="text-sm text-default-500">{t("share-link-expired-desc")}</p> + </Card> </div> - } - </div> - </GradientBackground> + </GradientBackground> + ); + } + + if (hasPassword && !store.shareNote?.value) { + return ( + <GradientBackground> + <div className='p-4 h-[100vh] w-full flex justify-center items-center'> + <Card className="p-6 flex flex-col gap-4 items-center glass-effect"> + <div className="flex items-center gap-2"> + <Icon icon="solar:lock-password-bold" className="text-2xl" /> + <span className="text-xl font-medium">{t("need-password-to-access")}</span> + </div> + <InputOtp + length={6} + value={password} + onValueChange={setPassword} + onComplete={handleVerify} + classNames={{ + input: error ? "border-danger" : "" + }} + /> + {error && <span className="text-danger text-sm">{t("password-error")}</span>} + <Button + color="primary" + className="w-full" + onPress={handleVerify} + isDisabled={password.length !== 6} + > + {t("verify")} + </Button> + </Card> + </div> + </GradientBackground> + ); + } + + return ( + <GradientBackground> + <div className='p-4 h-[100vh] w-full flex justify-center items-center'> + {store.shareNote?.value && ( + <div className="tilt-card glass-effect max-h-[90vh] overflow-y-scroll w-[95%] md:min-w-[30%] md:max-w-[50%] rounded-xl shadow-[1px_0_25px_11px_rgba(98,0,114,0.17)]"> + <BlinkoCard blinkoItem={store.shareNote?.value} isShareMode glassEffect /> + </div> + )} + </div> + </GradientBackground> + ); }); export default Page \ No newline at end of file diff --git a/src/server/routers/note.ts b/src/server/routers/note.ts index 13ce1cf6..5b89b139 100644 --- a/src/server/routers/note.ts +++ b/src/server/routers/note.ts @@ -197,15 +197,63 @@ export const noteRouter = router({ publicDetail: publicProcedure .meta({ openapi: { method: 'POST', path: '/v1/note/public-detail', summary: 'Query share note detail', tags: ['Note'] } }) .input(z.object({ - id: z.number(), + shareEncryptedUrl: z.string(), + password: z.string().optional() + })) + .output(z.object({ + hasPassword: z.boolean(), + data: z.union([z.null(), notesSchema.merge( + z.object({ + attachments: z.array(attachmentsSchema) + }) + )]), + error: z.union([z.literal('expired'), z.null()]).default(null) })) - .output(z.union([z.null(), notesSchema.merge( - z.object({ - attachments: z.array(attachmentsSchema) - }))])) .mutation(async function ({ input }) { - const { id } = input - return await prisma.notes.findFirst({ where: { id, isShare: true }, include: { tags: true, attachments: true }, }) + const { shareEncryptedUrl, password } = input + const note = await prisma.notes.findFirst({ + where: { + shareEncryptedUrl, + isShare: true + }, + include: { + tags: true, + attachments: true + } + }) + + if (!note) { + return { + hasPassword: false, + data: null + } + } + + if (note.shareExpiryDate && new Date() > note.shareExpiryDate) { + // throw new Error('Note expired') + return { + hasPassword: false, + data: null, + error: 'expired' + } + } + + if (note.sharePassword) { + if (!password) { + return { + hasPassword: true, + data: null + } + } + + if (password !== note.sharePassword) { + throw new Error('Password error') + } + } + return { + hasPassword: !!note.sharePassword, + data: note + } }), detail: authProcedure .meta({ openapi: { method: 'POST', path: '/v1/note/detail', summary: 'Query note detail', protect: true, tags: ['Note'] } }) @@ -452,6 +500,62 @@ export const noteRouter = router({ } } }), + + shareNote: authProcedure + .meta({ openapi: { method: 'POST', path: '/v1/note/share', summary: 'Share note', protect: true, tags: ['Note'] } }) + .input(z.object({ + id: z.number(), + isCancel: z.boolean().default(false), + password: z.string().optional(), + expireAt: z.date().optional() + })) + .output(notesSchema) + .mutation(async function ({ input, ctx }) { + const { id, isCancel, password, expireAt } = input; + + const generateShareId = () => { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < 8; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; + }; + + const note = await prisma.notes.findFirst({ + where: { + id, + accountId: Number(ctx.id) + } + }); + + if (!note) { + throw new Error('Note not found'); + } + + if (isCancel) { + return await prisma.notes.update({ + where: { id }, + data: { + isShare: false, + sharePassword: "", + shareExpiryDate: null, + shareEncryptedUrl: null + } + }); + } else { + const shareId = note.shareEncryptedUrl || generateShareId(); + return await prisma.notes.update({ + where: { id }, + data: { + isShare: true, + shareEncryptedUrl: shareId, + sharePassword: password, + shareExpiryDate: expireAt + } + }); + } + }), updateMany: authProcedure .meta({ openapi: { method: 'POST', path: '/v1/note/batch-update', summary: 'Batch update note', protect: true, tags: ['Note'] } }) .input(z.object({ diff --git a/src/server/routers/public.ts b/src/server/routers/public.ts index 380ea227..e2c654d3 100644 --- a/src/server/routers/public.ts +++ b/src/server/routers/public.ts @@ -165,7 +165,6 @@ export const publicRouter = router({ } if (!spotifyClient) { - spotifyClient = new SpotifyClient({ consumer: { key: config.spotifyConsumerKey!, diff --git a/src/store/blinkoStore.tsx b/src/store/blinkoStore.tsx index d506cb3a..1fe46b2e 100644 --- a/src/store/blinkoStore.tsx +++ b/src/store/blinkoStore.tsx @@ -184,6 +184,15 @@ export class BlinkoStore implements Store { } }) + shareNote = new PromiseState({ + function: async (params: { id: number, isCancel: boolean, password?: string, expireAt?: Date }) => { + const res = await api.notes.shareNote.mutate(params) + RootStore.Get(ToastPlugin).success(i18n.t("operation-success")) + this.updateTicker++ + return res + } + }) + async syncOfflineNotes() { if (!this.isOnline) return;