mirror of
https://github.com/SerTimBerrners-Lee/talkis.git
synced 2026-07-09 17:29:15 +00:00
Release v0.3.2
This commit is contained in:
parent
775d343e39
commit
70ec6f4d13
20 changed files with 835 additions and 200 deletions
9
.github/workflows/release.yml
vendored
9
.github/workflows/release.yml
vendored
|
|
@ -183,7 +183,14 @@ jobs:
|
|||
Automated release for ${{ env.RELEASE_TAG }}.
|
||||
|
||||
This release publishes native Tauri bundles built on macOS, Windows, and Linux runners.
|
||||
Windows/Linux builds are unsigned in this workflow.
|
||||
|
||||
### Первый запуск / First launch
|
||||
|
||||
Сборки пока без платной подписи кода — это **не вирус** (исходники открыты, AGPL-3.0). Если ОС блокирует запуск:
|
||||
The builds are not code-signed yet — this is **not malware** (open source, AGPL-3.0). If your OS blocks it:
|
||||
|
||||
- **macOS** — «не удалось проверить» / "could not verify": нажмите **Готово/Done**, затем **Системные настройки → Конфиденциальность и безопасность → Всё равно открыть** (System Settings → Privacy & Security → Open Anyway). Или: `xattr -dr com.apple.quarantine /Applications/Talkis.app`
|
||||
- **Windows** — SmartScreen «защитил ПК» / "protected your PC": **Подробнее → Выполнить в любом случае** (More info → Run anyway).
|
||||
draft: false
|
||||
prerelease: ${{ contains(env.RELEASE_TAG, '-') }}
|
||||
files: |
|
||||
|
|
|
|||
21
README.md
21
README.md
|
|
@ -123,6 +123,27 @@ Download the latest build from GitHub Releases:
|
|||
- [Windows x64 installer](https://github.com/SerTimBerrners-Lee/talkis/releases/latest/download/Talkis-windows-x64-setup.exe)
|
||||
- [Linux x64 AppImage](https://github.com/SerTimBerrners-Lee/talkis/releases/latest/download/Talkis-linux-x64.AppImage)
|
||||
|
||||
### If your OS blocks the first launch
|
||||
|
||||
Talkis is currently distributed **without paid code signing**, so Gatekeeper (macOS) and SmartScreen (Windows) show an "unverified app" warning. This is **not malware**: the source is open (AGPL-3.0) and the builds are produced by the public GitHub Actions workflow in this repo. How to open it:
|
||||
|
||||
**macOS** — "Apple could not verify Talkis is free of malware":
|
||||
|
||||
1. In the dialog click **Done** (not "Move to Trash").
|
||||
2. Open **System Settings → Privacy & Security**, scroll down to *"Talkis was blocked…"* and click **Open Anyway**.
|
||||
3. Confirm with your password or Touch ID and click **Open** again.
|
||||
|
||||
Or, in one command in Terminal (after moving it to Applications):
|
||||
|
||||
```bash
|
||||
xattr -dr com.apple.quarantine /Applications/Talkis.app
|
||||
```
|
||||
|
||||
**Windows** — the blue *"Windows protected your PC"* dialog:
|
||||
|
||||
1. Click **More info**.
|
||||
2. Click **Run anyway**.
|
||||
|
||||
On first launch, grant the permissions Talkis needs:
|
||||
|
||||
- Microphone access for recording.
|
||||
|
|
|
|||
21
README.ru.md
21
README.ru.md
|
|
@ -123,6 +123,27 @@ Talkis сделан для повседневной работы: IDE, чаты,
|
|||
- [Windows x64 installer](https://github.com/SerTimBerrners-Lee/talkis/releases/latest/download/Talkis-windows-x64-setup.exe)
|
||||
- [Linux x64 AppImage](https://github.com/SerTimBerrners-Lee/talkis/releases/latest/download/Talkis-linux-x64.AppImage)
|
||||
|
||||
### Если ОС блокирует первый запуск
|
||||
|
||||
Talkis пока распространяется **без платной подписи кода**, поэтому Gatekeeper (macOS) и SmartScreen (Windows) показывают предупреждение о «непроверённом» приложении. Это **не вирус**: исходный код открыт (AGPL-3.0), сборки собираются публичным GitHub Actions из этого репозитория. Как открыть:
|
||||
|
||||
**macOS** — «Не удалось проверить, что приложение Talkis не содержит вредоносного ПО»:
|
||||
|
||||
1. В диалоге нажмите **«Готово»** (не «Переместить в Корзину»).
|
||||
2. Откройте **Системные настройки → Конфиденциальность и безопасность**, пролистайте вниз до строки *«Talkis заблокирован…»* и нажмите **«Всё равно открыть»**.
|
||||
3. Подтвердите паролем или Touch ID и ещё раз нажмите **«Открыть»**.
|
||||
|
||||
Либо одной командой в Терминале (после переноса в «Программы»):
|
||||
|
||||
```bash
|
||||
xattr -dr com.apple.quarantine /Applications/Talkis.app
|
||||
```
|
||||
|
||||
**Windows** — синее окно *«Система Windows защитила ваш компьютер»*:
|
||||
|
||||
1. Нажмите **«Подробнее»**.
|
||||
2. Нажмите **«Выполнить в любом случае»**.
|
||||
|
||||
При первом запуске выдайте нужные разрешения:
|
||||
|
||||
- доступ к микрофону для записи;
|
||||
|
|
|
|||
43
docs/release/review-v0.3.2.md
Normal file
43
docs/release/review-v0.3.2.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# Release Review
|
||||
|
||||
## Release
|
||||
|
||||
- Version: 0.3.2
|
||||
- Release branch: release/v0.3.2
|
||||
- Target tag: v0.3.2
|
||||
- Reviewer: Codex
|
||||
- Date: 2026-06-30
|
||||
|
||||
## Scope
|
||||
|
||||
- Key changes included in this release: single-instance focus fix for Windows/Linux, auth flow cancellation guard, subscription/account card cleanup, main-page stats glass card with rotating help/support hint, settings navigation order update, first-launch signing guidance in README/site/release notes.
|
||||
- User-facing changes: widget should no longer duplicate on repeated launch on Windows/Linux; logout should not immediately re-authenticate from stale auth flow; subscription CTA/status states are clearer; the settings menu puts general settings at the bottom.
|
||||
- Risky areas: cloud auth deep-link/polling flow, settings model tab conditional rendering, release artifact workflow text, UI layout around the main stats panel.
|
||||
|
||||
## Checks run
|
||||
|
||||
- `git diff --check`: passed
|
||||
- `bun run check:release`: passed
|
||||
- `TAURI_SIGNING_PRIVATE_KEY_PATH=~/.tauri/talkis-updater.key bun run build:release:macos`: failed at DMG bundling in this sandboxed environment after `.app` build; `hdiutil` reports `Cannot start hdiejectd because app is sandboxed` / `Device not configured`.
|
||||
- Native/GitHub Windows build: expected via GitHub Actions after tag
|
||||
- Native/GitHub Linux build: expected via GitHub Actions after tag
|
||||
- Additional manual checks: release diff reviewed statically; no browser/Playwright pass requested.
|
||||
|
||||
## Manual review
|
||||
|
||||
- Hotkey flow: no hotkey registration code changed; main screen still renders the configured hotkey on the first hint slide.
|
||||
- Onboarding permissions: no permission/onboarding flow changes in this release.
|
||||
- Widget position and notice behavior: single-instance handler now unminimizes/shows/focuses the existing widget on Windows/Linux.
|
||||
- Transcription quality and short-utterance handling: no transcription pipeline or hallucination-filter changes in this release.
|
||||
- README refreshed: README.md and README.ru.md include first-launch instructions for unsigned builds.
|
||||
|
||||
## Findings
|
||||
|
||||
- Blockers: local macOS DMG packaging could not be completed in the current sandboxed environment.
|
||||
- Non-blocking issues: Windows/Linux release artifacts are validated by GitHub Actions runners, not locally on this macOS machine.
|
||||
- Follow-ups after release: continue paid code-signing/certification work to reduce first-launch OS warnings.
|
||||
|
||||
## Decision
|
||||
|
||||
- Ready for `main` merge: no, blocked until DMG build is validated outside this sandbox or by CI.
|
||||
- Ready for tag publish: no, blocked until release artifacts are validated.
|
||||
87
docs/release/trust-and-signing.md
Normal file
87
docs/release/trust-and-signing.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# Доверие ОС, подпись кода и нотаризация
|
||||
|
||||
Runbook по проблеме «после релиза все платформы расценивают Talkis как вредоносное ПО».
|
||||
|
||||
## Диагноз (корень проблемы)
|
||||
|
||||
Сборки Talkis **не подписаны платными сертификатами и не нотаризованы** ни под одну платформу.
|
||||
ОС реагируют на это предупреждениями о «непроверённом» приложении — это **не** реальная
|
||||
детекция вируса и **не** регрессия конкретного релиза.
|
||||
|
||||
- Единственная «подпись» в пайплайне — ключ апдейтера `TAURI_SIGNING_PRIVATE_KEY` (minisign).
|
||||
Он нужен только авто-апдейтеру для проверки подлинности обновлений и **не влияет на доверие ОС**.
|
||||
- **macOS** (`scripts/postprocess-macos-release.sh`): `codesign --force --deep --sign -` — это
|
||||
**ad-hoc** подпись без Apple Developer ID и **без нотаризации**. Скачанный `.dmg` получает
|
||||
атрибут `com.apple.quarantine`, и Gatekeeper показывает «Не удалось проверить, что приложение
|
||||
не содержит вредоносного ПО».
|
||||
- **Windows** (`.github/workflows/release.yml`): NSIS `.exe` **полностью без подписи** → SmartScreen
|
||||
«Система Windows защитила ваш компьютер». Бандл из нативных бинарей (`talkis-llm` = llama.cpp,
|
||||
`talkis-ffmpeg`, whisper-STT, открывают локальные порты 8011/18200) повышает шанс эвристического
|
||||
флага у Defender/сторонних AV.
|
||||
- **Linux**: AppImage/deb без подписи; обычно «вредоносом» не зовётся.
|
||||
|
||||
**Почему «после нового релиза».** Пайплайн никогда не подписывал нормально (v0.3.0 был таким же).
|
||||
Каждая новая неподписанная сборка — это новый неизвестный хеш: репутация в SmartScreen/Defender
|
||||
обнуляется, а релиз пошёл к реальным пользователям, которые скачивают файл (с quarantine-битом),
|
||||
а не собирают локально.
|
||||
|
||||
## Фаза 1 — бесплатные меры (сделано / делается)
|
||||
|
||||
Не убирают предупреждение полностью, но снимают трение и страх у пользователей.
|
||||
|
||||
- [x] Инструкция «как открыть» в `README.ru.md` / `README.md` (раздел «Если ОС блокирует первый запуск»).
|
||||
- [x] Та же инструкция в теле GitHub-релиза (`release.yml`, шаг *Publish GitHub release assets*).
|
||||
- [x] Блок «Первый запуск» на статической странице сайта (`site/index.html`) рядом со ссылками на загрузку.
|
||||
- [ ] Проверить сборку на [VirusTotal](https://www.virustotal.com/) — убедиться, что нет **реальных**
|
||||
детекций (а только репутационные предупреждения). Если конкретный движок флагит — отправить
|
||||
false-positive репорт (см. ниже).
|
||||
|
||||
### Инструкции для пользователей
|
||||
|
||||
**macOS** («не удалось проверить»):
|
||||
1. В диалоге — **«Готово»** (не «Переместить в Корзину»).
|
||||
2. **Системные настройки → Конфиденциальность и безопасность → «Всё равно открыть»**.
|
||||
3. Подтвердить паролем/Touch ID, ещё раз **«Открыть»**.
|
||||
Терминал-альтернатива: `xattr -dr com.apple.quarantine /Applications/Talkis.app`
|
||||
|
||||
> На macOS 15 Sequoia правый клик → «Открыть» больше не обходит Gatekeeper — нужен путь через
|
||||
> «Системные настройки». На Sonoma и раньше правый клик → «Открыть» ещё работает.
|
||||
|
||||
**Windows** (SmartScreen): **«Подробнее» → «Выполнить в любом случае»**.
|
||||
Либо ПКМ по `.exe` → Свойства → галочка **«Разблокировать»** → Применить.
|
||||
|
||||
### False-positive репорты (если есть реальная детекция)
|
||||
|
||||
- Microsoft Defender: https://www.microsoft.com/en-us/wdsi/filesubmission (как разработчик ПО).
|
||||
- VirusTotal: открыть отчёт по файлу, у флагнувших движков использовать форму «contact / false positive».
|
||||
|
||||
## Фаза 2 — настоящая подпись (когда будет бюджет)
|
||||
|
||||
Полностью убирает предупреждения. Здесь же — конкретные шаги, чтобы не вспоминать заново.
|
||||
|
||||
### macOS — Developer ID + нотаризация (~$99/год)
|
||||
|
||||
1. Apple Developer Program → сертификат **Developer ID Application**.
|
||||
2. В CI (`release.yml`, шаг сборки macOS) задать Tauri-переменные:
|
||||
- `APPLE_CERTIFICATE` (base64 .p12), `APPLE_CERTIFICATE_PASSWORD`
|
||||
- `APPLE_SIGNING_IDENTITY` (`Developer ID Application: … (TEAMID)`)
|
||||
- `APPLE_ID`, `APPLE_PASSWORD` (app-specific), `APPLE_TEAM_ID`
|
||||
Tauri тогда сам подпишет Developer ID и **нотаризует** бандл.
|
||||
3. Убрать/заменить ad-hoc `postprocess-macos-release.sh`. Если оставляем ручную подпись —
|
||||
**не использовать `--deep`** (deprecated, ломает подпись бандла с сайдкарами): подписывать
|
||||
вложенные бинарники по отдельности (inside-out), приложение — последним, с
|
||||
`--options runtime --timestamp --entitlements entitlements.plist`, затем `xcrun notarytool submit`
|
||||
и `xcrun stapler staple`.
|
||||
|
||||
### Windows — подпись installer'а
|
||||
|
||||
- Варианты: **EV-сертификат** (мгновенная репутация SmartScreen, дорого), обычный **OV-сертификат**
|
||||
(дешевле, но репутация копится), либо **Azure Trusted Signing** (~$10/мес, нужна верифицированная
|
||||
организация/личность).
|
||||
- В Tauri NSIS прописать `signCommand` (signtool / Azure) — подписывать `.exe`/`.msi`.
|
||||
|
||||
### Линки
|
||||
|
||||
- Tauri code signing: https://v2.tauri.app/distribute/sign/
|
||||
- Apple notarization: https://developer.apple.com/documentation/security/notarizing-macos-software-before-distribution
|
||||
- Azure Trusted Signing: https://learn.microsoft.com/azure/trusted-signing/
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "talkis",
|
||||
"private": true,
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.2.13",
|
||||
|
|
|
|||
|
|
@ -269,6 +269,27 @@
|
|||
left: 0;
|
||||
color: rgba(255,255,255,0.4);
|
||||
}
|
||||
|
||||
.first-launch-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.08);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.14em;
|
||||
color: #000;
|
||||
}
|
||||
.first-launch-link:hover { opacity: 0.55; }
|
||||
.first-launch-copy {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.75;
|
||||
color: #555;
|
||||
}
|
||||
.first-launch-copy strong { color: #000; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="selection:bg-black selection:text-white">
|
||||
|
|
@ -288,7 +309,7 @@
|
|||
<a href="#speed" class="hover:text-gray-400 transition-colors text-black/50">Скорость</a>
|
||||
<a href="#options" class="hover:text-gray-400 transition-colors text-black/50">Варианты</a>
|
||||
</div>
|
||||
<a href="#" class="btn-black px-4 py-2 md:px-6 md:py-2.5 rounded-full flex items-center space-x-2">
|
||||
<a href="#first-launch" class="btn-black px-4 py-2 md:px-6 md:py-2.5 rounded-full flex items-center space-x-2">
|
||||
<span class="platform-icon"></span>
|
||||
<span class="text-[9px] md:text-[10px]">скачать</span>
|
||||
</a>
|
||||
|
|
@ -306,16 +327,56 @@
|
|||
Мгновенная трансформация ваших мыслей в структурированный текст. Работает везде, где есть курсор.
|
||||
</p>
|
||||
<div class="flex flex-col items-center justify-center space-y-6 md:space-y-8">
|
||||
<button class="btn-black w-full sm:w-auto px-12 py-5 rounded-full text-[11px] shadow-xl flex items-center justify-center space-x-4">
|
||||
<a href="#first-launch" class="btn-black w-full sm:w-auto px-12 py-5 rounded-full text-[11px] shadow-xl flex items-center justify-center space-x-4">
|
||||
<span class="platform-icon-large"></span>
|
||||
<span>Скачать приложение</span>
|
||||
</button>
|
||||
</a>
|
||||
<div class="text-[8px] md:text-[9px] uppercase tracking-[0.3em] md:tracking-[0.4em] text-gray-400 font-bold px-4 text-center leading-loose">
|
||||
macOS Windows Linux Android iOS
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ПЕРВЫЙ ЗАПУСК -->
|
||||
<section id="first-launch" class="border-t border-b border-black/5 bg-white/45 reveal-node">
|
||||
<div class="max-w-7xl mx-auto px-6 py-14 md:py-20 grid grid-cols-1 lg:grid-cols-[4fr_6fr] gap-12 md:gap-16 items-start">
|
||||
<div>
|
||||
<p class="text-[9px] md:text-[10px] uppercase tracking-[0.3em] text-gray-400 font-bold mb-5">Download</p>
|
||||
<h2 class="text-3xl md:text-5xl font-bold tracking-tighter text-black mb-8">Первый запуск</h2>
|
||||
<div class="border-t border-black/10">
|
||||
<a class="first-launch-link" href="https://github.com/SerTimBerrners-Lee/talkis/releases/latest/download/Talkis-macos.dmg">
|
||||
<span>macOS DMG</span>
|
||||
<span>↓</span>
|
||||
</a>
|
||||
<a class="first-launch-link" href="https://github.com/SerTimBerrners-Lee/talkis/releases/latest/download/Talkis-windows-x64-setup.exe">
|
||||
<span>Windows x64</span>
|
||||
<span>↓</span>
|
||||
</a>
|
||||
<a class="first-launch-link" href="https://github.com/SerTimBerrners-Lee/talkis/releases/latest/download/Talkis-linux-x64.AppImage">
|
||||
<span>Linux AppImage</span>
|
||||
<span>↓</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-10 md:gap-12">
|
||||
<div>
|
||||
<h3 class="text-xs uppercase tracking-widest font-bold text-black mb-4">Почему ОС предупреждает</h3>
|
||||
<p class="first-launch-copy">
|
||||
Talkis пока распространяется <strong>без платной подписи кода</strong>, поэтому Gatekeeper и SmartScreen могут показать предупреждение о непроверенном приложении. Это репутационная проверка нового файла, а не признак вируса: исходный код открыт, а сборки создаются публичным GitHub Actions.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-xs uppercase tracking-widest font-bold text-black mb-4">Как открыть</h3>
|
||||
<p class="first-launch-copy">
|
||||
<strong>macOS:</strong> нажмите «Готово», затем откройте Системные настройки → Конфиденциальность и безопасность → «Всё равно открыть».<br><br>
|
||||
<strong>Windows:</strong> в окне SmartScreen нажмите «Подробнее» → «Выполнить в любом случае».
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ИНТЕГРАЦИИ -->
|
||||
<section id="integrations" class="py-12 md:py-16 border-t border-b border-black/5 overflow-hidden reveal-node">
|
||||
<div class="scroll-container">
|
||||
|
|
@ -429,7 +490,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-black w-full py-5 rounded-full shadow-lg text-[10px] mt-8">Скачать приложение</button>
|
||||
<a href="#first-launch" class="btn-black w-full py-5 rounded-full shadow-lg text-[10px] mt-8">Скачать приложение</a>
|
||||
</div>
|
||||
|
||||
<!-- КОРПОРАЦИЯ (40%) -->
|
||||
|
|
|
|||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -4969,7 +4969,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "talkis"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"block2 0.6.2",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "talkis"
|
||||
version = "0.3.1"
|
||||
version = "0.3.2"
|
||||
description = "Talkis - Voice to Text Desktop Widget"
|
||||
authors = ["trixter"]
|
||||
license = "AGPL-3.0-or-later"
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -21,7 +21,6 @@ use commands::{
|
|||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
/// Bring the already-running instance to front when a second launch is blocked
|
||||
/// by the single-instance plugin (Windows/Linux only).
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
|
|
@ -36,11 +35,13 @@ fn focus_existing_instance(app: &tauri::AppHandle) {
|
|||
}
|
||||
|
||||
if let Some(win) = app.get_webview_window("widget") {
|
||||
let _ = win.unminimize();
|
||||
let _ = win.show();
|
||||
let _ = win.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
#[allow(unused_mut)]
|
||||
let mut builder = tauri::Builder::default();
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Talkis",
|
||||
"mainBinaryName": "Talkis",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"identifier": "com.trixter.talkis",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run prepare:sidecars && bun run dev",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
import { logError } from "../lib/logger";
|
||||
|
|
@ -39,6 +39,10 @@ interface StatItem {
|
|||
value: string;
|
||||
}
|
||||
|
||||
interface TranscriptionStatsPanelProps {
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
function buildStats(view: TranscriptionStatsView, t: TranslateFn): StatItem[] {
|
||||
return [
|
||||
{
|
||||
|
|
@ -59,7 +63,9 @@ function buildStats(view: TranscriptionStatsView, t: TranslateFn): StatItem[] {
|
|||
];
|
||||
}
|
||||
|
||||
export function TranscriptionStatsPanel(): ReactElement {
|
||||
export function TranscriptionStatsPanel({
|
||||
footer,
|
||||
}: TranscriptionStatsPanelProps): ReactElement {
|
||||
const { t } = useI18n();
|
||||
const [view, setView] = useState<TranscriptionStatsView>(() => getEmptyView());
|
||||
|
||||
|
|
@ -95,31 +101,59 @@ export function TranscriptionStatsPanel(): ReactElement {
|
|||
|
||||
return (
|
||||
<section
|
||||
className="stats-glass-card"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
||||
gap: 16,
|
||||
padding: "14px 2px 18px",
|
||||
gap: 14,
|
||||
padding: "16px 18px 14px",
|
||||
borderRadius: "var(--radius-lg)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{stats.map(({ key, label, value }) => (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
||||
gap: 14,
|
||||
}}
|
||||
>
|
||||
{stats.map(({ key, label, value }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="stats-glass-metric"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 4,
|
||||
minWidth: 0,
|
||||
minHeight: 72,
|
||||
padding: "8px 10px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="headline-accent"
|
||||
style={{ fontSize: 28, color: "var(--text-hi)" }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
<span className="label">{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{footer ? (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
minWidth: 0,
|
||||
textAlign: "center",
|
||||
borderTop: "1px solid var(--border-subtle)",
|
||||
paddingTop: 12,
|
||||
}}
|
||||
>
|
||||
<span className="headline-accent" style={{ fontSize: 28, color: "var(--text-hi)" }}>
|
||||
{value}
|
||||
</span>
|
||||
<span className="label">{label}</span>
|
||||
{footer}
|
||||
</div>
|
||||
))}
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,22 @@ import { listen } from "@tauri-apps/api/event";
|
|||
import { onOpenUrl } from "@tauri-apps/plugin-deep-link";
|
||||
import { LogOut, User, Crown } from "lucide-react";
|
||||
|
||||
import { CloudProfile, fetchCloudProfile, cloudLogout, getAuthLoginUrl, handleAuthToken, generateExchangeCode, getAuthLoginUrlWithCode, pollForToken, getCachedCloudProfile, subscribeCloudProfile } from "../lib/cloudAuth";
|
||||
import {
|
||||
beginCloudAuthFlow,
|
||||
cancelCloudAuthFlow,
|
||||
CloudProfile,
|
||||
fetchCloudProfile,
|
||||
cloudLogout,
|
||||
getAuthLoginUrl,
|
||||
handleAuthToken,
|
||||
generateExchangeCode,
|
||||
getAuthLoginUrlWithCode,
|
||||
isCloudAuthFlowActive,
|
||||
pollForToken,
|
||||
getCachedCloudProfile,
|
||||
subscribeCloudProfile,
|
||||
type CloudAuthFlowId,
|
||||
} from "../lib/cloudAuth";
|
||||
import { logError, logInfo } from "../lib/logger";
|
||||
import { SETTINGS_UPDATED_EVENT } from "../lib/hotkeyEvents";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
|
|
@ -26,6 +41,34 @@ export function UserPanel() {
|
|||
const [waitingForAuth, setWaitingForAuth] = useState(false);
|
||||
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const exchangeCodeRef = useRef<string | null>(null);
|
||||
const authFlowRef = useRef<CloudAuthFlowId | null>(null);
|
||||
|
||||
const clearLocalAuthPolling = useCallback(() => {
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
exchangeCodeRef.current = null;
|
||||
authFlowRef.current = null;
|
||||
setWaitingForAuth(false);
|
||||
}, []);
|
||||
|
||||
const cancelLocalAuthPolling = useCallback(() => {
|
||||
cancelCloudAuthFlow();
|
||||
clearLocalAuthPolling();
|
||||
}, [clearLocalAuthPolling]);
|
||||
|
||||
const applyAuthTokenForCurrentFlow = useCallback(async (token: string): Promise<CloudProfile | null> => {
|
||||
const flowId = authFlowRef.current;
|
||||
if (!isCloudAuthFlowActive(flowId)) {
|
||||
logInfo("USER_PANEL", "Ignoring auth token without an active local auth flow");
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await handleAuthToken(token, { authFlowId: flowId });
|
||||
clearLocalAuthPolling();
|
||||
return data;
|
||||
}, [clearLocalAuthPolling]);
|
||||
|
||||
const loadProfile = useCallback(async () => {
|
||||
if (getCachedCloudProfile() === undefined) {
|
||||
|
|
@ -53,10 +96,10 @@ export function UserPanel() {
|
|||
setProfile(nextProfile);
|
||||
setLoading(false);
|
||||
if (nextProfile) {
|
||||
setWaitingForAuth(false);
|
||||
clearLocalAuthPolling();
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
}, [clearLocalAuthPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
const unlistenPromise = listen(SETTINGS_UPDATED_EVENT, () => {
|
||||
|
|
@ -72,14 +115,16 @@ export function UserPanel() {
|
|||
useEffect(() => {
|
||||
const unlistenPromise = listen<string>("deep-link-auth", async (event) => {
|
||||
logInfo("USER_PANEL", "Received auth token via Tauri event");
|
||||
await handleAuthToken(event.payload);
|
||||
await loadProfile();
|
||||
const data = await applyAuthTokenForCurrentFlow(event.payload);
|
||||
if (data) {
|
||||
await loadProfile();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
void unlistenPromise.then((unlisten) => unlisten());
|
||||
};
|
||||
}, [loadProfile]);
|
||||
}, [applyAuthTokenForCurrentFlow, loadProfile]);
|
||||
|
||||
// ── Deep link: JS plugin API ──────────────────────────────
|
||||
useEffect(() => {
|
||||
|
|
@ -93,8 +138,10 @@ export function UserPanel() {
|
|||
logInfo("USER_PANEL", `Deep link (JS): ${url}`);
|
||||
const token = extractTokenFromUrl(url);
|
||||
if (token) {
|
||||
await handleAuthToken(token);
|
||||
await loadProfile();
|
||||
const data = await applyAuthTokenForCurrentFlow(token);
|
||||
if (data) {
|
||||
await loadProfile();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -109,7 +156,7 @@ export function UserPanel() {
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadProfile]);
|
||||
}, [applyAuthTokenForCurrentFlow, loadProfile]);
|
||||
|
||||
// ── Polling fallback via exchange code ──────────────────────
|
||||
useEffect(() => {
|
||||
|
|
@ -124,56 +171,54 @@ export function UserPanel() {
|
|||
logInfo("USER_PANEL", `Starting auth polling with code: ${exchangeCodeRef.current?.slice(0, 8)}...`);
|
||||
pollingRef.current = setInterval(async () => {
|
||||
const code = exchangeCodeRef.current;
|
||||
if (!code) return;
|
||||
const flowId = authFlowRef.current;
|
||||
if (!code || !isCloudAuthFlowActive(flowId)) return;
|
||||
|
||||
const token = await pollForToken(code);
|
||||
if (token && exchangeCodeRef.current === code) {
|
||||
if (token && exchangeCodeRef.current === code && authFlowRef.current === flowId && isCloudAuthFlowActive(flowId)) {
|
||||
logInfo("USER_PANEL", "Auth polling: token received!");
|
||||
const data = await handleAuthToken(token);
|
||||
const data = await handleAuthToken(token, { authFlowId: flowId });
|
||||
if (data) {
|
||||
setProfile(data);
|
||||
}
|
||||
setWaitingForAuth(false);
|
||||
exchangeCodeRef.current = null;
|
||||
clearLocalAuthPolling();
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
// Stop polling after 2 minutes
|
||||
const timeout = setTimeout(() => {
|
||||
logInfo("USER_PANEL", "Auth polling timed out");
|
||||
setWaitingForAuth(false);
|
||||
cancelLocalAuthPolling();
|
||||
}, 120_000);
|
||||
|
||||
return () => {
|
||||
if (pollingRef.current) clearInterval(pollingRef.current);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [waitingForAuth]);
|
||||
}, [cancelLocalAuthPolling, clearLocalAuthPolling, waitingForAuth]);
|
||||
|
||||
const handleActivate = async () => {
|
||||
try {
|
||||
// Generate exchange code for polling
|
||||
const code = generateExchangeCode();
|
||||
exchangeCodeRef.current = code;
|
||||
if (profile) {
|
||||
await openUrl(getAuthLoginUrl().replace("/auth/login?device=true", "/dashboard"));
|
||||
return;
|
||||
}
|
||||
|
||||
const url = profile
|
||||
? `${getAuthLoginUrl().replace('/auth/login?device=true', '/dashboard')}`
|
||||
: getAuthLoginUrlWithCode(code);
|
||||
await openUrl(url);
|
||||
// Start polling for token via exchange code
|
||||
const code = generateExchangeCode();
|
||||
const flowId = beginCloudAuthFlow();
|
||||
exchangeCodeRef.current = code;
|
||||
authFlowRef.current = flowId;
|
||||
setWaitingForAuth(true);
|
||||
|
||||
await openUrl(getAuthLoginUrlWithCode(code));
|
||||
} catch (error) {
|
||||
cancelLocalAuthPolling();
|
||||
logError("USER_PANEL", `Failed to open auth URL: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (pollingRef.current) {
|
||||
clearInterval(pollingRef.current);
|
||||
pollingRef.current = null;
|
||||
}
|
||||
exchangeCodeRef.current = null;
|
||||
setWaitingForAuth(false);
|
||||
cancelLocalAuthPolling();
|
||||
setProfile(null);
|
||||
await cloudLogout();
|
||||
logInfo("USER_PANEL", "User logged out");
|
||||
|
|
|
|||
|
|
@ -240,6 +240,23 @@ select {
|
|||
color: var(--text-low);
|
||||
}
|
||||
|
||||
.subtle-row-text {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--text-low);
|
||||
}
|
||||
|
||||
.subtle-row-link {
|
||||
color: var(--text-low);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.subtle-row-link:hover {
|
||||
color: var(--text-hi);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -269,7 +286,7 @@ select {
|
|||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
border-radius: var(--radius-pill);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.18s ease,
|
||||
|
|
@ -415,6 +432,76 @@ select {
|
|||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.main-hero-slide {
|
||||
animation: slide-down 0.22s var(--ease-standard);
|
||||
}
|
||||
|
||||
.stats-glass-card {
|
||||
position: relative;
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.26), rgba(255, 255, 255, 0.07));
|
||||
border: 1px solid transparent;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.68),
|
||||
0 3px 10px rgba(39, 34, 27, 0.025);
|
||||
backdrop-filter: blur(26px) saturate(1.28);
|
||||
-webkit-backdrop-filter: blur(26px) saturate(1.28);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.stats-glass-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background:
|
||||
linear-gradient(115deg, rgba(255, 255, 255, 0.54), transparent 30%),
|
||||
linear-gradient(250deg, transparent 58%, rgba(255, 255, 255, 0.12));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stats-glass-card > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stats-glass-metric {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stats-glass-metric:not(:last-child)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
right: 0;
|
||||
bottom: 14px;
|
||||
width: 1px;
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .stats-glass-card {
|
||||
background:
|
||||
linear-gradient(145deg, rgba(255, 255, 255, 0.085), rgba(255, 255, 255, 0.025));
|
||||
border-color: transparent;
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.13),
|
||||
0 4px 12px rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .stats-glass-card::before {
|
||||
opacity: 0.12;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .stats-glass-metric:not(:last-child)::after {
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.main-hero-slide {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
|
|
|
|||
|
|
@ -16,10 +16,13 @@ import { SETTINGS_UPDATED_EVENT } from "./hotkeyEvents";
|
|||
const CLOUD_API_BASE = "https://talkis.ru";
|
||||
|
||||
type CloudProfileListener = (profile: CloudProfile | null | undefined) => void;
|
||||
export type CloudAuthFlowId = number;
|
||||
|
||||
let cachedCloudProfile: CloudProfile | null | undefined;
|
||||
let inflightCloudProfileRequest: Promise<CloudProfile | null> | null = null;
|
||||
let authRevision = 0;
|
||||
let authFlowRevision = 0;
|
||||
let activeAuthFlowId: CloudAuthFlowId | null = null;
|
||||
const cloudProfileListeners = new Set<CloudProfileListener>();
|
||||
|
||||
function notifyCloudProfileListeners(profile: CloudProfile | null | undefined): void {
|
||||
|
|
@ -66,6 +69,21 @@ export function subscribeCloudProfile(listener: CloudProfileListener): () => voi
|
|||
};
|
||||
}
|
||||
|
||||
export function beginCloudAuthFlow(): CloudAuthFlowId {
|
||||
authFlowRevision += 1;
|
||||
activeAuthFlowId = authFlowRevision;
|
||||
return activeAuthFlowId;
|
||||
}
|
||||
|
||||
export function cancelCloudAuthFlow(): void {
|
||||
authFlowRevision += 1;
|
||||
activeAuthFlowId = null;
|
||||
}
|
||||
|
||||
export function isCloudAuthFlowActive(flowId: CloudAuthFlowId | null | undefined): flowId is CloudAuthFlowId {
|
||||
return typeof flowId === "number" && activeAuthFlowId === flowId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user profile and subscription status from the cloud.
|
||||
* Returns null if token is missing or invalid.
|
||||
|
|
@ -132,8 +150,20 @@ export async function fetchCloudProfile({ force = false }: { force?: boolean } =
|
|||
/**
|
||||
* Save device token received from deep link callback.
|
||||
*/
|
||||
export async function handleAuthToken(token: string): Promise<CloudProfile | null> {
|
||||
export async function handleAuthToken(
|
||||
token: string,
|
||||
options: { authFlowId?: CloudAuthFlowId | null } = {},
|
||||
): Promise<CloudProfile | null> {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(options, "authFlowId") &&
|
||||
!isCloudAuthFlowActive(options.authFlowId)
|
||||
) {
|
||||
logInfo("CLOUD", "Ignoring auth token from stale or cancelled auth flow");
|
||||
return cachedCloudProfile ?? null;
|
||||
}
|
||||
|
||||
logInfo("CLOUD", "Received auth token from deep link");
|
||||
activeAuthFlowId = null;
|
||||
authRevision += 1;
|
||||
await saveCloudSettings({ deviceToken: token, useOwnKey: false });
|
||||
return fetchCloudProfile({ force: true });
|
||||
|
|
@ -144,6 +174,7 @@ export async function handleAuthToken(token: string): Promise<CloudProfile | nul
|
|||
*/
|
||||
export async function cloudLogout(): Promise<void> {
|
||||
logInfo("CLOUD", "Logging out");
|
||||
cancelCloudAuthFlow();
|
||||
authRevision += 1;
|
||||
inflightCloudProfileRequest = null;
|
||||
setCachedCloudProfile(null);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,11 @@ export const settingsRest = {
|
|||
ru: "Удерживайте горячую клавишу, говорите и отпустите ее, когда закончите. После обработки текст вставится автоматически.",
|
||||
en: "Hold the hotkey, speak and release it when you're done. After processing the text is pasted automatically.",
|
||||
},
|
||||
"mainTab.hero.supportTitle": {
|
||||
ru: "Нашли ошибку или есть вопрос?",
|
||||
en: "Found a bug or have a question?",
|
||||
},
|
||||
"mainTab.hero.supportAction": { ru: "Написать", en: "Write" },
|
||||
"mainTab.combination": { ru: "Комбинация", en: "Combination" },
|
||||
"mainTab.historyTitle": { ru: "История записей", en: "Recording history" },
|
||||
"mainTab.historyDescFilled": {
|
||||
|
|
|
|||
|
|
@ -86,12 +86,6 @@ const TABS: { id: Tab; labelKey: MsgKey; icon: LucideIcon; note: string }[] = [
|
|||
icon: Languages,
|
||||
note: "Realtime Interpreter beta",
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
labelKey: "settingsApp.tab.settings",
|
||||
icon: Sliders,
|
||||
note: "Язык, микрофон и горячая клавиша",
|
||||
},
|
||||
{
|
||||
id: "model",
|
||||
labelKey: "settingsApp.tab.model",
|
||||
|
|
@ -99,6 +93,12 @@ const TABS: { id: Tab; labelKey: MsgKey; icon: LucideIcon; note: string }[] = [
|
|||
note: "Ключи и подключение модели",
|
||||
},
|
||||
{ id: "style", labelKey: "settingsApp.tab.style", icon: Sparkles, note: "Стиль обработки и Промпты для саммари" },
|
||||
{
|
||||
id: "settings",
|
||||
labelKey: "settingsApp.tab.settings",
|
||||
icon: Sliders,
|
||||
note: "Язык, микрофон и горячая клавиша",
|
||||
},
|
||||
];
|
||||
|
||||
function TabButton({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import { useState, useEffect, useMemo } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import type {
|
||||
FocusEvent as ReactFocusEvent,
|
||||
PointerEvent as ReactPointerEvent,
|
||||
ReactElement,
|
||||
} from "react";
|
||||
import { emit, listen } from "@tauri-apps/api/event";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import {
|
||||
clearHistory,
|
||||
DEFAULT_HOTKEY,
|
||||
|
|
@ -60,12 +65,31 @@ type HistorySource = "voice" | "file" | "call";
|
|||
type HistoryFilter = "all" | HistorySource;
|
||||
|
||||
const HISTORY_TEXT_PREVIEW_LIMIT = 250;
|
||||
const SUPPORT_EMAIL = "david.perov60@gmail.com";
|
||||
const MAIN_HERO_FIRST_SLIDE_DELAY_MS = 30_000;
|
||||
const MAIN_HERO_SLIDE_DELAY_MS = 30_000;
|
||||
const HISTORY_FILTER_OPTIONS: { id: HistoryFilter; labelKey: MsgKey }[] = [
|
||||
{ id: "all", labelKey: "mainTab.filter.all" },
|
||||
{ id: "voice", labelKey: "mainTab.filter.voice" },
|
||||
{ id: "file", labelKey: "mainTab.filter.file" },
|
||||
{ id: "call", labelKey: "mainTab.filter.call" },
|
||||
];
|
||||
const MAIN_HERO_SLIDES = [
|
||||
{
|
||||
id: "record",
|
||||
titleKey: "mainTab.howToStart",
|
||||
actionKey: "mainTab.howItWorks",
|
||||
},
|
||||
{
|
||||
id: "support",
|
||||
titleKey: "mainTab.hero.supportTitle",
|
||||
actionKey: "mainTab.hero.supportAction",
|
||||
},
|
||||
] as const satisfies readonly {
|
||||
id: "record" | "support";
|
||||
titleKey: MsgKey;
|
||||
actionKey: MsgKey;
|
||||
}[];
|
||||
|
||||
function getHistorySource(entry: HistoryEntry): HistorySource {
|
||||
if (entry.source === "file" || entry.source === "call") {
|
||||
|
|
@ -282,12 +306,16 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
|
|||
const [isClearArmed, setIsClearArmed] = useState(false);
|
||||
const [historyFilter, setHistoryFilter] = useState<HistoryFilter>("all");
|
||||
const [hintHelpOpen, setHintHelpOpen] = useState(false);
|
||||
const [heroSlideIndex, setHeroSlideIndex] = useState(0);
|
||||
const [heroHasAdvanced, setHeroHasAdvanced] = useState(false);
|
||||
const [heroPaused, setHeroPaused] = useState(false);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set());
|
||||
const [editingSpeakerEntryId, setEditingSpeakerEntryId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [summaryEntry, setSummaryEntry] = useState<HistoryEntry | null>(null);
|
||||
const [summaryAvailable, setSummaryAvailable] = useState(false);
|
||||
const heroPointerStartX = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const syncHotkeyLabel = async (reload = false) => {
|
||||
|
|
@ -333,6 +361,24 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
|
|||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (heroPaused || MAIN_HERO_SLIDES.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = window.setTimeout(
|
||||
() => {
|
||||
setHeroSlideIndex((current) => (current + 1) % MAIN_HERO_SLIDES.length);
|
||||
setHeroHasAdvanced(true);
|
||||
},
|
||||
heroHasAdvanced
|
||||
? MAIN_HERO_SLIDE_DELAY_MS
|
||||
: MAIN_HERO_FIRST_SLIDE_DELAY_MS,
|
||||
);
|
||||
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [heroHasAdvanced, heroPaused, heroSlideIndex]);
|
||||
|
||||
const deleteEntry = async (id: string) => {
|
||||
await deleteHistoryEntry(id);
|
||||
setHistory((h) => h.filter((x) => x.id !== id));
|
||||
|
|
@ -364,6 +410,33 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
|
|||
);
|
||||
};
|
||||
|
||||
const contactSupport = async (): Promise<void> => {
|
||||
const subject = encodeURIComponent(
|
||||
t("settingsGeneralExtra.support.mailSubject"),
|
||||
);
|
||||
const body = encodeURIComponent(
|
||||
t("settingsGeneralExtra.support.mailBody"),
|
||||
);
|
||||
const mailto = `mailto:${SUPPORT_EMAIL}?subject=${subject}&body=${body}`;
|
||||
|
||||
try {
|
||||
await openUrl(mailto);
|
||||
} catch (error) {
|
||||
void logError(
|
||||
"MAIN",
|
||||
`Failed to open support mail: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
try {
|
||||
await navigator.clipboard.writeText(SUPPORT_EMAIL);
|
||||
} catch (clipboardError) {
|
||||
void logError(
|
||||
"MAIN",
|
||||
`Failed to copy support mail: ${clipboardError instanceof Error ? clipboardError.message : String(clipboardError)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const editEntry = (entry: HistoryEntry): void => {
|
||||
if (!entry.segments?.length || !entry.speakers?.length) {
|
||||
toggleExpanded(entry.id);
|
||||
|
|
@ -543,90 +616,196 @@ export function MainTab({ initialHistory = [] }: MainTabProps) {
|
|||
return groups;
|
||||
}, [filteredHistory, t, lang]);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<section
|
||||
className="card"
|
||||
const activeHeroSlide =
|
||||
MAIN_HERO_SLIDES[heroSlideIndex] ?? MAIN_HERO_SLIDES[0];
|
||||
const isSupportHeroSlide = activeHeroSlide.id === "support";
|
||||
|
||||
const showAdjacentHeroSlide = (direction: 1 | -1): void => {
|
||||
setHeroSlideIndex(
|
||||
(current) =>
|
||||
(current + direction + MAIN_HERO_SLIDES.length) %
|
||||
MAIN_HERO_SLIDES.length,
|
||||
);
|
||||
setHeroHasAdvanced(true);
|
||||
};
|
||||
|
||||
const handleHeroAction = (): void => {
|
||||
if (isSupportHeroSlide) {
|
||||
void contactSupport();
|
||||
return;
|
||||
}
|
||||
|
||||
setHintHelpOpen((value) => !value);
|
||||
};
|
||||
|
||||
const handleHeroPointerDown = (
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
): void => {
|
||||
if (event.pointerType === "mouse" && event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
heroPointerStartX.current = event.clientX;
|
||||
};
|
||||
|
||||
const handleHeroPointerUp = (
|
||||
event: ReactPointerEvent<HTMLElement>,
|
||||
): void => {
|
||||
const startX = heroPointerStartX.current;
|
||||
heroPointerStartX.current = null;
|
||||
|
||||
if (startX === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = event.clientX - startX;
|
||||
if (Math.abs(deltaX) < 48) {
|
||||
return;
|
||||
}
|
||||
|
||||
showAdjacentHeroSlide(deltaX < 0 ? 1 : -1);
|
||||
};
|
||||
|
||||
const handleHeroBlur = (event: ReactFocusEvent<HTMLElement>): void => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
||||
setHeroPaused(false);
|
||||
}
|
||||
};
|
||||
|
||||
const heroHintFooter = (
|
||||
<div
|
||||
onMouseEnter={() => setHeroPaused(true)}
|
||||
onMouseLeave={() => setHeroPaused(false)}
|
||||
onFocus={() => setHeroPaused(true)}
|
||||
onBlur={handleHeroBlur}
|
||||
onPointerDown={handleHeroPointerDown}
|
||||
onPointerUp={handleHeroPointerUp}
|
||||
onPointerCancel={() => {
|
||||
heroPointerStartX.current = null;
|
||||
}}
|
||||
style={{ display: "grid", gap: 8, touchAction: "pan-y" }}
|
||||
>
|
||||
<div
|
||||
key={activeHeroSlide.id}
|
||||
className="main-hero-slide"
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 12,
|
||||
padding: 18,
|
||||
background: "var(--surface)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 16,
|
||||
flexWrap: "wrap",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 16,
|
||||
flexWrap: "wrap",
|
||||
gap: 8,
|
||||
flex: "1 1 220px",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "grid", gap: 6, maxWidth: 560 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span
|
||||
style={{ fontSize: 16, fontWeight: 700, color: "var(--text-hi)" }}
|
||||
>
|
||||
{t("mainTab.howToStart")}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHintHelpOpen((value) => !value)}
|
||||
aria-expanded={hintHelpOpen}
|
||||
aria-label={t("mainTab.howItWorks")}
|
||||
title={t("mainTab.howItWorks")}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 20,
|
||||
height: 20,
|
||||
padding: 0,
|
||||
border: "none",
|
||||
background: "none",
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
color: hintHelpOpen ? "var(--text-hi)" : "var(--text-low)",
|
||||
}}
|
||||
>
|
||||
<HelpCircle size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
{hintHelpOpen && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: "var(--text-mid)",
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
>
|
||||
{t("mainTab.howToStartHint")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
<span
|
||||
className="subtle-row-text"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
color: "var(--text-hi)",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.02em",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}
|
||||
>
|
||||
<span style={{ color: "var(--text-low)", letterSpacing: "0.08em" }}>
|
||||
{t("mainTab.combination")}
|
||||
</span>
|
||||
<span>{hotkeyLabel}</span>
|
||||
</div>
|
||||
{t(activeHeroSlide.titleKey)}
|
||||
</span>
|
||||
{!isSupportHeroSlide && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleHeroAction}
|
||||
aria-expanded={hintHelpOpen}
|
||||
aria-label={t(activeHeroSlide.actionKey)}
|
||||
title={t(activeHeroSlide.actionKey)}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 20,
|
||||
height: 20,
|
||||
padding: 0,
|
||||
border: "none",
|
||||
background: "none",
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
color: hintHelpOpen ? "var(--text-hi)" : "var(--text-low)",
|
||||
}}
|
||||
>
|
||||
<HelpCircle size={16} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<TranscriptionStatsPanel />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 14,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{isSupportHeroSlide && (
|
||||
<button
|
||||
type="button"
|
||||
className="subtle-row-text subtle-row-link"
|
||||
onClick={handleHeroAction}
|
||||
aria-label={t(activeHeroSlide.actionKey)}
|
||||
title={t(activeHeroSlide.actionKey)}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 0,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{t(activeHeroSlide.actionKey)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isSupportHeroSlide && (
|
||||
<div
|
||||
className="subtle-row-text"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<span>{t("mainTab.combination")}</span>
|
||||
<span>{hotkeyLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isSupportHeroSlide && hintHelpOpen && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: "var(--text-mid)",
|
||||
lineHeight: 1.65,
|
||||
}}
|
||||
>
|
||||
{t("mainTab.howToStartHint")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<TranscriptionStatsPanel footer={heroHintFooter} />
|
||||
|
||||
<section style={{ display: "grid", gap: 14 }}>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -38,7 +38,22 @@ import {
|
|||
upsertPrompt,
|
||||
deletePrompt,
|
||||
} from "../../../lib/store";
|
||||
import { CloudProfile, fetchCloudProfile, getAuthLoginUrl, cloudLogout, handleAuthToken, generateExchangeCode, getAuthLoginUrlWithCode, pollForToken, getCachedCloudProfile, subscribeCloudProfile } from "../../../lib/cloudAuth";
|
||||
import {
|
||||
beginCloudAuthFlow,
|
||||
cancelCloudAuthFlow,
|
||||
CloudProfile,
|
||||
fetchCloudProfile,
|
||||
getAuthLoginUrl,
|
||||
cloudLogout,
|
||||
handleAuthToken,
|
||||
generateExchangeCode,
|
||||
getAuthLoginUrlWithCode,
|
||||
isCloudAuthFlowActive,
|
||||
pollForToken,
|
||||
getCachedCloudProfile,
|
||||
subscribeCloudProfile,
|
||||
type CloudAuthFlowId,
|
||||
} from "../../../lib/cloudAuth";
|
||||
import { logInfo } from "../../../lib/logger";
|
||||
|
||||
import { TRANSCRIPTION_STYLE_OPTIONS } from "../../../lib/transcriptionPrompts";
|
||||
|
|
@ -608,12 +623,12 @@ function CloudSubscriptionAccountCard({
|
|||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="card" style={{ padding: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14, padding: "2px 2px 4px" }}>
|
||||
<div className="card" style={{ padding: "22px 20px", borderRadius: 10, background: "var(--control-muted)", color: "var(--text-hi)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14, paddingBottom: 16, marginBottom: 16, borderBottom: "1px solid var(--border-subtle)" }}>
|
||||
<div
|
||||
style={{
|
||||
width: 46,
|
||||
height: 46,
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: "50%",
|
||||
background: "var(--avatar-bg)",
|
||||
display: "flex",
|
||||
|
|
@ -681,49 +696,21 @@ function CloudSubscriptionAccountCard({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: "center", marginTop: 10, fontSize: 13, fontWeight: 700, color: "var(--accent)" }}>
|
||||
{t("models.cta.freeTrial")}
|
||||
</div>
|
||||
<button
|
||||
onClick={onActivate}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
width: "100%",
|
||||
margin: "8px 0 0",
|
||||
padding: "12px 14px",
|
||||
borderRadius: 8,
|
||||
background: "var(--accent)",
|
||||
color: "var(--accent-contrast)",
|
||||
border: "none",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
lineHeight: 1,
|
||||
whiteSpace: "nowrap",
|
||||
cursor: "pointer",
|
||||
transition: "opacity 0.15s",
|
||||
fontFamily: "var(--font)",
|
||||
}}
|
||||
>
|
||||
<Crown size={13} strokeWidth={2} color="var(--accent-contrast)" />
|
||||
<span style={{ display: "flex", alignItems: "center", lineHeight: 1, whiteSpace: "nowrap" }}>{t("models.cta.upgradePro")}</span>
|
||||
</button>
|
||||
<SubscriptionPromoContent onActivate={onActivate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubscriptionGuestCard({ onActivate }: { onActivate: () => void }) {
|
||||
function SubscriptionPromoContent({ onActivate }: { onActivate: () => void }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="card" style={{ padding: "22px 20px", borderRadius: 10, background: "var(--control-muted)", color: "var(--text-hi)" }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 14, flexWrap: "wrap" }}>
|
||||
<>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 10, flexWrap: "wrap" }}>
|
||||
<Crown size={16} strokeWidth={2.2} />
|
||||
<span style={{ fontWeight: 700, fontSize: 14, letterSpacing: "-0.02em" }}>{t("models.guest.title")}</span>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--accent)" }}>{t("models.cta.freeTrial")}</span>
|
||||
<div style={{ display: "flex", alignItems: "baseline", gap: 6, minWidth: 0, flexWrap: "wrap" }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 14, letterSpacing: "-0.02em", lineHeight: 1.2 }}>{t("models.guest.title")}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--text-low)", lineHeight: 1.2 }}>{t("models.cta.freeTrial")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 14px", fontSize: 12, lineHeight: 2, opacity: 0.85 }}>
|
||||
<li>{t("models.guest.benefit1")}</li>
|
||||
|
|
@ -733,6 +720,14 @@ function SubscriptionGuestCard({ onActivate }: { onActivate: () => void }) {
|
|||
<button onClick={onActivate} style={{ width: "100%", padding: "12px", borderRadius: 10, background: "var(--accent)", color: "var(--accent-contrast)", border: "none", fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.08em", cursor: "pointer", transition: "opacity 0.15s", fontFamily: "var(--font-main)" }}>
|
||||
{t("models.cta.upgradePro")}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SubscriptionGuestCard({ onActivate }: { onActivate: () => void }) {
|
||||
return (
|
||||
<div className="card" style={{ padding: "22px 20px", borderRadius: 10, background: "var(--control-muted)", color: "var(--text-hi)" }}>
|
||||
<SubscriptionPromoContent onActivate={onActivate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1243,8 +1238,24 @@ export function SettingsTabs({ type }: SettingsTabsProps) {
|
|||
const [localModelActionStates, setLocalModelActionStates] = useState<Partial<Record<string, LocalModelActionState>>>({});
|
||||
const authPollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const exchangeCodeRef = useRef<string | null>(null);
|
||||
const authFlowRef = useRef<CloudAuthFlowId | null>(null);
|
||||
const settingsSaveQueueRef = useRef<Promise<void>>(Promise.resolve());
|
||||
|
||||
const clearLocalAuthPolling = useCallback(() => {
|
||||
if (authPollingRef.current) {
|
||||
clearInterval(authPollingRef.current);
|
||||
authPollingRef.current = null;
|
||||
}
|
||||
exchangeCodeRef.current = null;
|
||||
authFlowRef.current = null;
|
||||
setWaitingForAuth(false);
|
||||
}, []);
|
||||
|
||||
const cancelLocalAuthPolling = useCallback(() => {
|
||||
cancelCloudAuthFlow();
|
||||
clearLocalAuthPolling();
|
||||
}, [clearLocalAuthPolling]);
|
||||
|
||||
const syncSettings = useCallback(async () => {
|
||||
const nextSettings = await getSettings({ reload: true });
|
||||
setSettings(nextSettings);
|
||||
|
|
@ -1257,14 +1268,19 @@ export function SettingsTabs({ type }: SettingsTabsProps) {
|
|||
}, []);
|
||||
|
||||
const applyCloudToken = useCallback(async (token: string) => {
|
||||
await handleAuthToken(token);
|
||||
const flowId = authFlowRef.current;
|
||||
if (!isCloudAuthFlowActive(flowId)) {
|
||||
logInfo("SETTINGS", "Ignoring auth token without an active local auth flow");
|
||||
return null;
|
||||
}
|
||||
|
||||
await handleAuthToken(token, { authFlowId: flowId });
|
||||
clearLocalAuthPolling();
|
||||
await syncSettings();
|
||||
const profile = await loadCloudProfile();
|
||||
setWaitingForAuth(false);
|
||||
exchangeCodeRef.current = null;
|
||||
setWaitingForSubscriptionRefresh(!profile?.subscription.active);
|
||||
return profile;
|
||||
}, [loadCloudProfile, syncSettings]);
|
||||
}, [clearLocalAuthPolling, loadCloudProfile, syncSettings]);
|
||||
|
||||
const refreshLocalInstalledModels = useCallback(async () => {
|
||||
if (!settings || type !== "model" || !settings.useOwnKey || settings.provider !== "custom") {
|
||||
|
|
@ -1350,8 +1366,11 @@ export function SettingsTabs({ type }: SettingsTabsProps) {
|
|||
useEffect(() => {
|
||||
return subscribeCloudProfile((nextProfile) => {
|
||||
setCloudProfile(nextProfile);
|
||||
if (nextProfile) {
|
||||
clearLocalAuthPolling();
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
}, [clearLocalAuthPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshCloudProfile = () => {
|
||||
|
|
@ -1424,18 +1443,18 @@ export function SettingsTabs({ type }: SettingsTabsProps) {
|
|||
|
||||
authPollingRef.current = setInterval(async () => {
|
||||
const code = exchangeCodeRef.current;
|
||||
if (!code) return;
|
||||
const flowId = authFlowRef.current;
|
||||
if (!code || !isCloudAuthFlowActive(flowId)) return;
|
||||
|
||||
const token = await pollForToken(code);
|
||||
if (!token || exchangeCodeRef.current !== code) return;
|
||||
if (!token || exchangeCodeRef.current !== code || authFlowRef.current !== flowId || !isCloudAuthFlowActive(flowId)) return;
|
||||
|
||||
logInfo("SETTINGS", "Auth polling returned device token");
|
||||
await applyCloudToken(token);
|
||||
}, 3000);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setWaitingForAuth(false);
|
||||
exchangeCodeRef.current = null;
|
||||
cancelLocalAuthPolling();
|
||||
}, 120_000);
|
||||
|
||||
return () => {
|
||||
|
|
@ -1445,7 +1464,7 @@ export function SettingsTabs({ type }: SettingsTabsProps) {
|
|||
}
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [applyCloudToken, waitingForAuth]);
|
||||
}, [applyCloudToken, cancelLocalAuthPolling, waitingForAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!waitingForSubscriptionRefresh) {
|
||||
|
|
@ -1595,21 +1614,18 @@ export function SettingsTabs({ type }: SettingsTabsProps) {
|
|||
}
|
||||
|
||||
const code = generateExchangeCode();
|
||||
const flowId = beginCloudAuthFlow();
|
||||
exchangeCodeRef.current = code;
|
||||
authFlowRef.current = flowId;
|
||||
setWaitingForAuth(true);
|
||||
await openUrl(getAuthLoginUrlWithCode(code));
|
||||
} catch {
|
||||
// Error handled silently
|
||||
cancelLocalAuthPolling();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloudLogout = async () => {
|
||||
if (authPollingRef.current) {
|
||||
clearInterval(authPollingRef.current);
|
||||
authPollingRef.current = null;
|
||||
}
|
||||
exchangeCodeRef.current = null;
|
||||
setWaitingForAuth(false);
|
||||
cancelLocalAuthPolling();
|
||||
setWaitingForSubscriptionRefresh(false);
|
||||
await cloudLogout();
|
||||
setCloudProfile(null);
|
||||
|
|
@ -1617,7 +1633,6 @@ export function SettingsTabs({ type }: SettingsTabsProps) {
|
|||
};
|
||||
|
||||
if (type === "model") {
|
||||
const isAuthenticated = cloudProfile !== null && cloudProfile !== undefined;
|
||||
const hasActiveSubscription = cloudProfile?.subscription.active === true;
|
||||
const isCloudMode = !settings.useOwnKey;
|
||||
const isCustom = settings.provider === "custom";
|
||||
|
|
@ -2477,14 +2492,12 @@ export function SettingsTabs({ type }: SettingsTabsProps) {
|
|||
}}
|
||||
/>
|
||||
|
||||
{isAuthenticated && (
|
||||
{hasActiveSubscription && (
|
||||
<div className="card" style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: "var(--text-hi)", marginBottom: 4 }}>{t("models.mode.cloud")}</div>
|
||||
<div style={{ fontSize: 13, color: "var(--text-mid)", lineHeight: 1.6 }}>
|
||||
{hasActiveSubscription
|
||||
? t("models.cloud.descActive")
|
||||
: t("models.cloud.descGuest")}
|
||||
{t("models.cloud.descActive")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue