Skip to content

Commit 9bd2dda

Browse files
authored
Merge pull request #17 from codebridger/CU-86exfjner_Implement-single-translate-on-popup-page_Navid-Shad
Enhance Popup Translation UI with Loading State, Input, and Help View Refresh #86exfjner
2 parents f67bd59 + 9c89f83 commit 9bd2dda

9 files changed

Lines changed: 548 additions & 293 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ dist
66
static/key-file.json
77
*.zip
88
.npmrc
9+
/.claude

CLAUDE.md

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Load `dist/` as an unpacked extension at `chrome://extensions`. There is no sepa
1919
| `main.js` | [src/main.ts](src/main.ts) | YouTube `/watch`, Netflix | Subtitle phrase collector — wraps caption words in `<Word>` spans, hover/anchor selection. |
2020
| `nibble.js` | [src/nibble.ts](src/nibble.ts) | `<all_urls>` | Web text phrase collector — native `Selection` → floating Subturtle icon → translation card. **Does not mutate page DOM.** |
2121
| `console-crane.js` | [src/console-crane.ts](src/console-crane.ts) | `<all_urls>` | The modal app (word-detail, settings, save flow). Owns its own Vue app + Pinia store + router. Feature bundles drive it via the [bridge](src/common/services/console-crane-bridge.ts). |
22-
| `popup.js` | [src/popup.ts](src/popup.ts) | Toolbar popup | Settings, language, dashboard link, per-site Nibble toggle. |
22+
| `popup.js` | [src/popup.ts](src/popup.ts) | Toolbar popup | Ad-hoc text translation (input + detailed result), settings, language, dashboard link, per-site Nibble toggle. Reuses console-crane's `WordDetailModule` for the result panel — see [Shared APIs § WordDetailModule](#worddetailmodule-detailed-translation-panel) for the cross-bundle reuse rules. |
2323
| `background.js` | [src/background.ts](src/background.ts) | Service worker | OAuth, token storage, settings persistence to `chrome.storage.local`, broadcast `SYNC_SETTINGS` to tabs. |
2424

2525
Manifest content_scripts split is in [static/manifest.json](static/manifest.json). On a YouTube `/watch` page all three content scripts run side-by-side in the same isolated world — `main.js`, `nibble.js`, and `console-crane.js` — so they coordinate through shared `chrome.storage` (settings) and `window` CustomEvents (the ConsoleCrane bridge).
@@ -77,7 +77,9 @@ The console-crane content script listens (`onOpen` in [src/console-crane.ts](src
7777

7878
**Inside the console-crane bundle itself**, code can keep using `useConsoleCraneStore()` directly — it's the same Vue app. Bridge events are only the cross-bundle path.
7979

80-
Params are encoded into the route via `encodeRouteParams` in [src/console-crane/stores/console-crane.ts](src/console-crane/stores/console-crane.ts) — Unicode-safe (uses TextEncoder). Decode with `decodeRouteParams`. **Never use `window.btoa(JSON.stringify(...))` directly** — it throws `InvalidCharacterError` on non-Latin1 input (Persian, CJK, emoji, accented Latin).
80+
Params are encoded into the route via `encodeRouteParams` from [src/console-crane/route-params.ts](src/console-crane/route-params.ts) — Unicode-safe (uses TextEncoder). Decode with `decodeRouteParams`. **Never use `window.btoa(JSON.stringify(...))` directly** — it throws `InvalidCharacterError` on non-Latin1 input (Persian, CJK, emoji, accented Latin).
81+
82+
These helpers live in their own module (separate from the store) so consumers can import them without dragging in the console-crane router. Importing them from the store would close a circular ESM init when the importer isn't `console-crane.ts` itself (popup → WordDetailModule → store → router → WordDetailModule). Keep them in `route-params.ts`.
8183

8284
### Translation
8385

@@ -88,6 +90,25 @@ const text = await TranslateService.instance.fetchSimpleTranslation(phrase, cont
8890

8991
24-hour in-memory cache keyed on `(translationType, targetLanguage, phrase, context)`. `fetchDetailedTranslation` for the rich `LanguageLearningData` shape used by ConsoleCrane.
9092

93+
### WordDetailModule (detailed translation panel)
94+
95+
[src/console-crane/modules/word-detail/index.vue](src/console-crane/modules/word-detail/index.vue) is the rich result panel — definition, phonetic, examples, related expressions, plus the bundle save UI from [SaveWordSectionV2](src/console-crane/components/SaveWordSectionV2.vue). It runs its own `fetchDetailedTranslation` call internally, so the caller just supplies inputs.
96+
97+
It supports two mounting modes:
98+
99+
- **Route-driven** (console-crane): mounted by the console-crane router; reads `{ word, context }` from the base64-encoded `:data` route param. This is what `emitOpen({ page: "word-detail", params })` ultimately drives.
100+
- **Prop-driven** (popup, anywhere outside the console-crane router): pass `:word` and optional `:context` directly. When `word` is present it's preferred over the route param.
101+
102+
Also emits `loading: boolean` mirroring its internal pending state — bind it on the parent (e.g. the popup's [TranslateCard](src/popup/components/TranslateCard.vue)) to reflect a button spinner.
103+
104+
**Cross-bundle reuse caveat.** The "feature bundles never import the ConsoleCrane component or its store" rule is about the modal wrapper and `useConsoleCraneStore` — they're for opening the modal on a page that already has the ConsoleCrane content script. Reusing presentational sub-modules like `WordDetailModule` (and the things it transitively pulls in: `SaveWordSectionV2`, `SelectPhraseBundleV2`, `FreemiumLimitCounter`) **is fine** as long as:
105+
106+
1. You're in a bundle that does NOT also load `console-crane.js` (today: only the popup qualifies — it's its own Chrome extension page, not a content script).
107+
2. You drive the module via props, not by trying to inject route params it doesn't have.
108+
3. The host app installs Pinia + the modular-rest auth plugin before mount (`addPlugins(app)` from [src/plugins/install.ts](src/plugins/install.ts)).
109+
110+
If you ever need this from a content-script bundle that runs alongside ConsoleCrane, use the [bridge](#consolecrane-bridge) instead — don't double-mount the same component on the same page.
111+
91112
### Settings store
92113

93114
[src/common/store/settings.ts](src/common/store/settings.ts) — Pinia store, syncs through background via `SYNC_SETTINGS`. Holds:
@@ -216,7 +237,8 @@ When changes touch the bundle layout, content scripts, or shared CSS:
216237
- On YouTube `/watch`: subtitle popup works; Nibble selection popup also works (all three content scripts run there). Exactly one `#subturtle-console-crane-root` in the DOM.
217238
- On Wikipedia: only `nibble.js` and `console-crane.js` run; selection → icon → translation card → save flow opens ConsoleCrane.
218239
- In the popup: per-site toggle reads/writes `nibbleDisabledDomains` and survives a popup re-open. Toggling Nibble OFF for a host **while ConsoleCrane is open** must NOT close the modal or lock page scroll — the modal lifecycle is decoupled from the Nibble per-host gate via the bridge.
219-
- In ConsoleCrane on a non-Latin page (e.g. Persian / Chinese article): no `InvalidCharacterError` from `btoa`.
240+
- In the popup translate input: input is auto-focused on open; submitting renders the detailed result inline; logged-out users see "Login to save this phrase"; logged-in users get the bundle picker. Re-translating a different word resets the result. The button shows a spinner while pending.
241+
- In ConsoleCrane on a non-Latin page (e.g. Persian / Chinese article): no `InvalidCharacterError` from `btoa`. Same check applies to the popup translate input — paste a Persian / CJK phrase and confirm no encoding error.
220242
- Visual scale is consistent on a default-html-font-size site (YouTube) and a large-html-font-size site (typical WordPress blog).
221243

222244
## Useful pointers
@@ -228,6 +250,9 @@ When changes touch the bundle layout, content scripts, or shared CSS:
228250
- Background message types: [src/common/types/messaging.ts](src/common/types/messaging.ts)
229251
- ConsoleCrane store: [src/console-crane/stores/console-crane.ts](src/console-crane/stores/console-crane.ts)
230252
- ConsoleCrane bridge: [src/common/services/console-crane-bridge.ts](src/common/services/console-crane-bridge.ts)
253+
- Route-param helpers (Unicode-safe base64): [src/console-crane/route-params.ts](src/console-crane/route-params.ts)
254+
- WordDetailModule (detailed result panel, prop- or route-driven): [src/console-crane/modules/word-detail/index.vue](src/console-crane/modules/word-detail/index.vue)
255+
- Popup translate card: [src/popup/components/TranslateCard.vue](src/popup/components/TranslateCard.vue)
231256
- Settings store: [src/common/store/settings.ts](src/common/store/settings.ts)
232257
- Marker store: [src/stores/marker.ts](src/stores/marker.ts)
233258
- Translate service: [src/common/services/translate.service.ts](src/common/services/translate.service.ts)

src/console-crane/modules/word-detail/index.vue

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,16 @@ import { useRoute } from "vue-router";
192192
import { sendMessage } from "../../../common/helper/massage";
193193
import { OpenLoginWindowMessage } from "../../../common/types/messaging";
194194
import { analytic } from "../../../plugins/mixpanel";
195-
import { decodeRouteParams } from "../../stores/console-crane";
195+
import { decodeRouteParams } from "../../route-params";
196+
197+
const props = defineProps<{
198+
word?: string;
199+
context?: string;
200+
}>();
201+
202+
const emit = defineEmits<{
203+
loading: [boolean];
204+
}>();
196205
197206
const route = useRoute();
198207
@@ -201,10 +210,15 @@ onMounted(() => {
201210
});
202211
203212
/**
204-
* Extracts word data from the route parameter
205-
* The data is base64 encoded in the URL
213+
* Resolve word + context inputs. Prefers explicit props (used by the popup
214+
* bundle, which mounts this module without a route param). Falls back to
215+
* the base64-encoded `:data` route param used by the console-crane router.
206216
*/
207217
function getProps() {
218+
if (props.word) {
219+
return { word: props.word, context: props.context ?? "" };
220+
}
221+
208222
const data = decodeRouteParams<{ word: string; context?: string }>(
209223
route.params.data as string
210224
);
@@ -222,6 +236,9 @@ const pending = ref(false); // Loading state
222236
const error = ref<string | null>(null); // Translation error message, null when ok
223237
const key = ref(new Date().getTime()); // Key for forcing component refresh
224238
239+
// Mirror loading state to parent so popup callers can show a button spinner.
240+
watch(pending, (val) => emit("loading", val));
241+
225242
// Gets the title of the target language (e.g., "Spanish", "French")
226243
const targetLanguageTitle = computed(
227244
() => TranslateService.instance.languageTitle

src/console-crane/route-params.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Unicode-safe base64 helpers for vue-router params. Lives in its own module
2+
// (no router/store imports) so consumers like the popup's WordDetailModule
3+
// can pull only what they need without dragging in the console-crane router
4+
// — that import path causes a circular ESM init in non-console-crane bundles.
5+
//
6+
// `btoa` only accepts Latin1 — any non-Latin1 character (e.g. accented Latin,
7+
// Persian, Chinese, emoji) throws InvalidCharacterError. We encode via
8+
// TextEncoder so route params can carry any text.
9+
export function encodeRouteParams(params: any): string {
10+
const bytes = new TextEncoder().encode(JSON.stringify(params));
11+
let binary = "";
12+
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
13+
return btoa(binary);
14+
}
15+
16+
export function decodeRouteParams<T = any>(data: string): T {
17+
const binary = atob(data);
18+
const bytes = new Uint8Array(binary.length);
19+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
20+
return JSON.parse(new TextDecoder().decode(bytes));
21+
}

src/console-crane/stores/console-crane.ts

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,13 @@ import { ref, computed } from "vue";
33
import { ConsolePage } from "../types";
44

55
import { router } from "../router";
6+
import { encodeRouteParams } from "../route-params";
67

78
interface PageEntry {
89
name: ConsolePage;
910
params?: Record<string, any>;
1011
}
1112

12-
// Unicode-safe base64. `btoa` only accepts Latin1 — any non-Latin1 character
13-
// (e.g. accented Latin, Persian, Chinese, emoji) throws InvalidCharacterError.
14-
// We encode via TextEncoder so route params can carry any text.
15-
export function encodeRouteParams(params: any): string {
16-
const bytes = new TextEncoder().encode(JSON.stringify(params));
17-
let binary = "";
18-
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
19-
return btoa(binary);
20-
}
21-
22-
export function decodeRouteParams<T = any>(data: string): T {
23-
const binary = atob(data);
24-
const bytes = new Uint8Array(binary.length);
25-
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
26-
return JSON.parse(new TextDecoder().decode(bytes));
27-
}
28-
2913
export const useConsoleCraneStore = defineStore("console-crane", () => {
3014
const isActive = ref(false);
3115
const history = ref<PageEntry[]>([]);
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<template>
2+
<section class="space-y-4">
3+
<div
4+
class="bg-gray-50 dark:bg-white/[0.03] backdrop-blur-xl rounded-xl p-4 border border-gray-200 dark:border-white/[0.08] shadow-xl hover:border-gray-300 dark:hover:border-white/[0.12] transition-all duration-300"
5+
>
6+
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-1">
7+
Translate any text
8+
</h3>
9+
<p class="text-gray-600 dark:text-gray-400 text-sm mb-3">
10+
Type or paste any phrase to see its detailed translation. Save it to your
11+
bundles when logged in.
12+
</p>
13+
14+
<form class="flex gap-2" @submit.prevent="submit">
15+
<input
16+
ref="inputEl"
17+
v-model="inputText"
18+
type="text"
19+
:placeholder="placeholder"
20+
autocomplete="off"
21+
spellcheck="false"
22+
class="flex-1 px-3 py-2 rounded-md bg-white dark:bg-white/[0.04] border border-gray-200 dark:border-white/[0.08] text-sm text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-400/40 focus:border-purple-400/40"
23+
/>
24+
<button
25+
type="submit"
26+
:disabled="!inputText.trim() || loading"
27+
class="px-5 py-2 rounded-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 text-white text-sm font-medium shadow-lg hover:shadow-purple-500/25 transition-all transform hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100 disabled:hover:shadow-none whitespace-nowrap inline-flex items-center justify-center gap-2 min-w-[7rem]"
28+
>
29+
<svg
30+
v-if="loading"
31+
class="animate-spin h-4 w-4 text-white"
32+
xmlns="http://www.w3.org/2000/svg"
33+
fill="none"
34+
viewBox="0 0 24 24"
35+
>
36+
<circle
37+
class="opacity-25"
38+
cx="12"
39+
cy="12"
40+
r="10"
41+
stroke="currentColor"
42+
stroke-width="4"
43+
/>
44+
<path
45+
class="opacity-75"
46+
fill="currentColor"
47+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
48+
/>
49+
</svg>
50+
{{ loading ? "Translating…" : "Translate" }}
51+
</button>
52+
</form>
53+
</div>
54+
55+
<div v-if="submittedWord" class="rounded-xl overflow-hidden">
56+
<WordDetailModule :word="submittedWord" @loading="loading = $event" />
57+
</div>
58+
</section>
59+
</template>
60+
61+
<script setup lang="ts">
62+
import { ref, onMounted, nextTick } from "vue";
63+
import WordDetailModule from "../../console-crane/modules/word-detail/index.vue";
64+
65+
const placeholder = "Type or paste any text to translate…";
66+
67+
const inputEl = ref<HTMLInputElement | null>(null);
68+
const inputText = ref("");
69+
const submittedWord = ref("");
70+
const loading = ref(false);
71+
72+
onMounted(async () => {
73+
await nextTick();
74+
inputEl.value?.focus();
75+
});
76+
77+
function submit() {
78+
const text = inputText.value.trim();
79+
// Skip if empty or unchanged — re-submitting the same word wouldn't trigger
80+
// WordDetailModule's prop watcher, so the loading flag would never clear.
81+
if (!text || text === submittedWord.value) return;
82+
// Set immediately for snappy feedback; WordDetailModule's emit will
83+
// turn it off when the fetch settles (or hand it back to true on retry).
84+
loading.value = true;
85+
submittedWord.value = text;
86+
}
87+
</script>

src/popup/router.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,22 +37,19 @@ export const router = createRouter({
3737
routes: routes,
3838
});
3939

40-
router.beforeEach(async (to, from) => {
41-
// Allow access to intro and login pages regardless of login status
40+
router.beforeEach(async (to) => {
41+
// Intro and login pages bypass the silent re-auth attempt entirely.
4242
if (to.name === "intro" || to.name === "login") {
4343
return true;
4444
}
4545

46-
// Try to login with last session if not already logged in
46+
// Best-effort silent re-auth so logged-in users hit a populated state on
47+
// first paint. We deliberately do NOT redirect on failure — the home view
48+
// (and the new translation card on it) is reachable for logged-out users;
49+
// auth-gated UI inside HomeView is hidden via `v-if="isLogin"`.
4750
if (!isLogin.value) {
4851
await loginWithLastSession();
49-
50-
// After trying to login, check again if successful
51-
if (!isLogin.value) {
52-
return { name: "intro" };
53-
}
5452
}
5553

56-
// If we got here, user is logged in, allow navigation
5754
return true;
5855
});

0 commit comments

Comments
 (0)