Skip to content

Commit 9c89f83

Browse files
navidshadclaude
andcommitted
feat: add loading state and section divider to popup translate
- Translate button shows a spinner + "Translating…" while the fetch is in flight; disabled while loading. WordDetailModule emits a `loading: boolean` event mirroring its internal pending ref so any caller can reflect the state, and TranslateCard skips no-op resubmits (same word) that wouldn't trigger the prop watcher and would otherwise leave the spinner stuck. - HomeView gets a subtle horizontal gradient divider between the translate card and the settings cards below so the result detail doesn't visually bleed into the rest of the popup. - CLAUDE.md: document the new WordDetailModule prop-driven mounting mode, the cross-bundle reuse carve-out (WordDetailModule etc. are reusable from non-content-script bundles like the popup), the new route-params.ts helper file location, and the popup translate verification flow. - Ignore /.claude config dir in .gitignore. #86exfjner Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a0968ec commit 9c89f83

5 files changed

Lines changed: 75 additions & 8 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:
@@ -207,7 +228,8 @@ When changes touch the bundle layout, content scripts, or shared CSS:
207228
- 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.
208229
- On Wikipedia: only `nibble.js` and `console-crane.js` run; selection → icon → translation card → save flow opens ConsoleCrane.
209230
- 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.
210-
- In ConsoleCrane on a non-Latin page (e.g. Persian / Chinese article): no `InvalidCharacterError` from `btoa`.
231+
- 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.
232+
- 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.
211233
- Visual scale is consistent on a default-html-font-size site (YouTube) and a large-html-font-size site (typical WordPress blog).
212234

213235
## Useful pointers
@@ -219,6 +241,9 @@ When changes touch the bundle layout, content scripts, or shared CSS:
219241
- Background message types: [src/common/types/messaging.ts](src/common/types/messaging.ts)
220242
- ConsoleCrane store: [src/console-crane/stores/console-crane.ts](src/console-crane/stores/console-crane.ts)
221243
- ConsoleCrane bridge: [src/common/services/console-crane-bridge.ts](src/common/services/console-crane-bridge.ts)
244+
- Route-param helpers (Unicode-safe base64): [src/console-crane/route-params.ts](src/console-crane/route-params.ts)
245+
- WordDetailModule (detailed result panel, prop- or route-driven): [src/console-crane/modules/word-detail/index.vue](src/console-crane/modules/word-detail/index.vue)
246+
- Popup translate card: [src/popup/components/TranslateCard.vue](src/popup/components/TranslateCard.vue)
222247
- Settings store: [src/common/store/settings.ts](src/common/store/settings.ts)
223248
- Marker store: [src/stores/marker.ts](src/stores/marker.ts)
224249
- Translate service: [src/common/services/translate.service.ts](src/common/services/translate.service.ts)

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,10 @@ const props = defineProps<{
199199
context?: string;
200200
}>();
201201
202+
const emit = defineEmits<{
203+
loading: [boolean];
204+
}>();
205+
202206
const route = useRoute();
203207
204208
onMounted(() => {
@@ -232,6 +236,9 @@ const pending = ref(false); // Loading state
232236
const error = ref<string | null>(null); // Translation error message, null when ok
233237
const key = ref(new Date().getTime()); // Key for forcing component refresh
234238
239+
// Mirror loading state to parent so popup callers can show a button spinner.
240+
watch(pending, (val) => emit("loading", val));
241+
235242
// Gets the title of the target language (e.g., "Spanish", "French")
236243
const targetLanguageTitle = computed(
237244
() => TranslateService.instance.languageTitle

src/popup/components/TranslateCard.vue

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,37 @@
2323
/>
2424
<button
2525
type="submit"
26-
:disabled="!inputText.trim()"
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"
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]"
2828
>
29-
Translate
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" }}
3051
</button>
3152
</form>
3253
</div>
3354

3455
<div v-if="submittedWord" class="rounded-xl overflow-hidden">
35-
<WordDetailModule :word="submittedWord" />
56+
<WordDetailModule :word="submittedWord" @loading="loading = $event" />
3657
</div>
3758
</section>
3859
</template>
@@ -46,6 +67,7 @@ const placeholder = "Type or paste any text to translate…";
4667
const inputEl = ref<HTMLInputElement | null>(null);
4768
const inputText = ref("");
4869
const submittedWord = ref("");
70+
const loading = ref(false);
4971
5072
onMounted(async () => {
5173
await nextTick();
@@ -54,7 +76,12 @@ onMounted(async () => {
5476
5577
function submit() {
5678
const text = inputText.value.trim();
57-
if (!text) return;
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;
5885
submittedWord.value = text;
5986
}
6087
</script>

src/popup/views/HomeView.vue

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@
88
<!-- Translate any text -->
99
<TranslateCard />
1010

11+
<!-- Section divider between the translation surface and the rest of
12+
the popup so the result detail doesn't visually bleed into the
13+
settings cards below. -->
14+
<div
15+
class="h-px bg-gradient-to-r from-transparent via-gray-300/70 to-transparent dark:via-white/[0.12]"
16+
></div>
17+
1118
<!-- Control Panel -->
1219
<div
1320
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"

0 commit comments

Comments
 (0)