Skip to content

Commit b3b3869

Browse files
author
HugoFara
committed
refactor(multi-word): moves the feature to the front-end.
1 parent 7657942 commit b3b3869

File tree

14 files changed

+966
-114
lines changed

14 files changed

+966
-114
lines changed
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
<?php declare(strict_types=1);
2+
/**
3+
* Multi-Word Modal View (Bulma + Alpine.js)
4+
*
5+
* Displays multi-word expression form for creating/editing terms.
6+
* Works with the multiWordModal Alpine.js component and multiWordForm store.
7+
*
8+
* PHP version 8.1
9+
*
10+
* @category Views
11+
* @package Lwt
12+
* @license Unlicense <http://unlicense.org/>
13+
* @since 3.0.0
14+
*/
15+
16+
namespace Lwt\Views\Text;
17+
18+
?>
19+
<div x-data="multiWordModal" x-cloak>
20+
<div class="modal" :class="{ 'is-active': isOpen }">
21+
<div class="modal-background" @click="close"></div>
22+
<div class="modal-card" style="max-width: 500px;">
23+
<header class="modal-card-head py-3">
24+
<p class="modal-card-title is-size-6" x-text="modalTitle"></p>
25+
<button class="delete" aria-label="close" @click="close" :disabled="isLoading || isSubmitting"></button>
26+
</header>
27+
<section class="modal-card-body">
28+
<!-- Loading overlay -->
29+
<div x-show="isLoading" class="has-text-centered py-4">
30+
<span class="icon is-large">
31+
<i class="fas fa-spinner fa-spin" aria-hidden="true"></i>
32+
</span>
33+
<p class="mt-2">Loading...</p>
34+
</div>
35+
36+
<!-- Form content -->
37+
<template x-if="!isLoading">
38+
<div>
39+
<!-- General error message -->
40+
<template x-if="store.errors.general">
41+
<div class="notification is-danger is-light mb-4">
42+
<button class="delete" @click="store.errors.general = null"></button>
43+
<span x-text="store.errors.general"></span>
44+
</div>
45+
</template>
46+
47+
<!-- Multi-word text (read-only) -->
48+
<div class="field">
49+
<label class="label is-small">Multi-Word Expression</label>
50+
<div class="control">
51+
<input class="input" type="text" :value="store.formData.text" disabled>
52+
</div>
53+
<p class="help" x-text="store.formData.wordCount + ' words'"></p>
54+
</div>
55+
56+
<!-- Translation -->
57+
<div class="field">
58+
<label class="label is-small">Translation</label>
59+
<div class="control">
60+
<textarea
61+
class="textarea"
62+
:class="{ 'is-danger': store.errors.translation }"
63+
x-model="store.formData.translation"
64+
@blur="store.validateField('translation')"
65+
rows="2"
66+
placeholder="Enter translation..."
67+
></textarea>
68+
</div>
69+
<template x-if="store.errors.translation">
70+
<p class="help is-danger" x-text="store.errors.translation"></p>
71+
</template>
72+
</div>
73+
74+
<!-- Romanization (if enabled for language) -->
75+
<template x-if="store.showRomanization">
76+
<div class="field">
77+
<label class="label is-small">Romanization</label>
78+
<div class="control">
79+
<input
80+
class="input"
81+
:class="{ 'is-danger': store.errors.romanization }"
82+
type="text"
83+
x-model="store.formData.romanization"
84+
@blur="store.validateField('romanization')"
85+
placeholder="Enter romanization..."
86+
>
87+
</div>
88+
<template x-if="store.errors.romanization">
89+
<p class="help is-danger" x-text="store.errors.romanization"></p>
90+
</template>
91+
</div>
92+
</template>
93+
94+
<!-- Sentence -->
95+
<div class="field">
96+
<label class="label is-small">Example Sentence</label>
97+
<div class="control">
98+
<textarea
99+
class="textarea"
100+
:class="{ 'is-danger': store.errors.sentence }"
101+
x-model="store.formData.sentence"
102+
@blur="store.validateField('sentence')"
103+
rows="2"
104+
placeholder="Example sentence with {term} in braces..."
105+
></textarea>
106+
</div>
107+
<template x-if="store.errors.sentence">
108+
<p class="help is-danger" x-text="store.errors.sentence"></p>
109+
</template>
110+
<p class="help">Use {curly braces} around the term</p>
111+
</div>
112+
113+
<!-- Status (1-5 only for multi-words) -->
114+
<div class="field">
115+
<label class="label is-small">Status</label>
116+
<div class="buttons are-small">
117+
<template x-for="s in statuses" :key="s.value">
118+
<button
119+
type="button"
120+
class="button"
121+
:class="getStatusButtonClass(s.value)"
122+
@click="setStatus(s.value)"
123+
x-text="s.abbr"
124+
:title="s.label"
125+
></button>
126+
</template>
127+
</div>
128+
</div>
129+
130+
<!-- Action buttons -->
131+
<div class="field is-grouped mt-5">
132+
<div class="control">
133+
<button
134+
type="button"
135+
class="button is-primary"
136+
:class="{ 'is-loading': isSubmitting }"
137+
:disabled="!store.canSubmit"
138+
@click="save"
139+
>
140+
<?php echo \Lwt\View\Helper\IconHelper::render('save', ['size' => 16]); ?>
141+
<span class="ml-1">Save</span>
142+
</button>
143+
</div>
144+
<div class="control">
145+
<button type="button" class="button" @click="close" :disabled="isSubmitting">
146+
Cancel
147+
</button>
148+
</div>
149+
</div>
150+
</div>
151+
</template>
152+
</section>
153+
</div>
154+
</div>
155+
</div>

src/backend/Views/Text/read_desktop.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ class="content"
124124

125125
<!-- Word modal -->
126126
<?php include __DIR__ . '/word_modal.php'; ?>
127+
128+
<!-- Multi-word modal -->
129+
<?php include __DIR__ . '/multi_word_modal.php'; ?>
127130
</div>
128131

129132
<style>

src/frontend/js/main.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,10 @@ import './terms/term_operations';
5050
// Reading interface
5151
import './reading/stores/word_store';
5252
import './reading/stores/word_form_store';
53+
import './reading/stores/multi_word_form_store';
5354
import './reading/components/word_modal';
5455
import './reading/components/word_edit_form';
56+
import './reading/components/multi_word_modal';
5557
import './reading/components/text_reader';
5658
import './reading/text_renderer';
5759
import './reading/text_events';
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/**
2+
* Multi-Word Modal - Alpine.js component for multi-word expression editing.
3+
*
4+
* Displays a form for creating or editing multi-word expressions.
5+
* Uses Bulma modal styling.
6+
*
7+
* @license Unlicense <http://unlicense.org/>
8+
* @since 3.0.0
9+
*/
10+
11+
import Alpine from 'alpinejs';
12+
import type { MultiWordFormStoreState } from '../stores/multi_word_form_store';
13+
import { initIcons } from '../../ui/lucide_icons';
14+
15+
/**
16+
* Status display information.
17+
*/
18+
interface StatusInfo {
19+
value: number;
20+
label: string;
21+
abbr: string;
22+
class: string;
23+
}
24+
25+
/**
26+
* Status definitions for learning words (1-5 only for multi-words).
27+
*/
28+
const STATUSES: StatusInfo[] = [
29+
{ value: 1, label: 'Learning (1)', abbr: '1', class: 'is-danger' },
30+
{ value: 2, label: 'Learning (2)', abbr: '2', class: 'is-warning' },
31+
{ value: 3, label: 'Learning (3)', abbr: '3', class: 'is-info' },
32+
{ value: 4, label: 'Learning (4)', abbr: '4', class: 'is-primary' },
33+
{ value: 5, label: 'Learned', abbr: '5', class: 'is-success' }
34+
];
35+
36+
/**
37+
* Multi-word modal Alpine.js component interface.
38+
*/
39+
export interface MultiWordModalData {
40+
// Computed properties
41+
readonly store: MultiWordFormStoreState;
42+
readonly isOpen: boolean;
43+
readonly isLoading: boolean;
44+
readonly isSubmitting: boolean;
45+
readonly modalTitle: string;
46+
readonly statuses: StatusInfo[];
47+
48+
// Lifecycle
49+
init(): void;
50+
51+
// Methods
52+
close(): void;
53+
save(): Promise<void>;
54+
setStatus(status: number): void;
55+
isCurrentStatus(status: number): boolean;
56+
getStatusButtonClass(status: number): string;
57+
}
58+
59+
/**
60+
* Create the multi-word modal Alpine.js component data.
61+
*/
62+
export function multiWordModalData(): MultiWordModalData {
63+
return {
64+
// Initialize icons when modal opens
65+
init(): void {
66+
Alpine.effect(() => {
67+
if (this.store.isVisible) {
68+
requestAnimationFrame(() => {
69+
initIcons();
70+
});
71+
}
72+
});
73+
},
74+
75+
/**
76+
* Get the multi-word form store.
77+
*/
78+
get store(): MultiWordFormStoreState {
79+
return Alpine.store('multiWordForm') as MultiWordFormStoreState;
80+
},
81+
82+
/**
83+
* Check if modal is open.
84+
*/
85+
get isOpen(): boolean {
86+
return this.store.isVisible;
87+
},
88+
89+
/**
90+
* Check if loading.
91+
*/
92+
get isLoading(): boolean {
93+
return this.store.isLoading;
94+
},
95+
96+
/**
97+
* Check if submitting.
98+
*/
99+
get isSubmitting(): boolean {
100+
return this.store.isSubmitting;
101+
},
102+
103+
/**
104+
* Get modal title.
105+
*/
106+
get modalTitle(): string {
107+
if (this.store.isNewWord) {
108+
return `New Multi-Word Expression (${this.store.formData.wordCount} words)`;
109+
}
110+
return `Edit Multi-Word Expression (${this.store.formData.wordCount} words)`;
111+
},
112+
113+
/**
114+
* Get available statuses.
115+
*/
116+
get statuses(): StatusInfo[] {
117+
return STATUSES;
118+
},
119+
120+
/**
121+
* Close the modal.
122+
*/
123+
close(): void {
124+
this.store.close();
125+
},
126+
127+
/**
128+
* Save the form.
129+
*/
130+
async save(): Promise<void> {
131+
const result = await this.store.save();
132+
133+
if (result.success) {
134+
// Close modal on success
135+
this.store.reset();
136+
}
137+
// On error, store.errors.general will be set and displayed
138+
},
139+
140+
/**
141+
* Set the status value.
142+
*/
143+
setStatus(status: number): void {
144+
this.store.formData.status = status;
145+
},
146+
147+
/**
148+
* Check if a status is the current status.
149+
*/
150+
isCurrentStatus(status: number): boolean {
151+
return this.store.formData.status === status;
152+
},
153+
154+
/**
155+
* Get Bulma button class for a status.
156+
*/
157+
getStatusButtonClass(status: number): string {
158+
const statusInfo = STATUSES.find(s => s.value === status);
159+
const base = 'button is-small';
160+
const colorClass = statusInfo?.class || '';
161+
162+
if (this.isCurrentStatus(status)) {
163+
return `${base} ${colorClass}`;
164+
}
165+
return `${base} is-outlined ${colorClass}`;
166+
}
167+
};
168+
}
169+
170+
/**
171+
* Register the multi-word modal as an Alpine.js component.
172+
*/
173+
export function registerMultiWordModal(): void {
174+
Alpine.data('multiWordModal', multiWordModalData);
175+
}
176+
177+
// Register the component immediately
178+
registerMultiWordModal();
179+
180+
// Expose for global access
181+
declare global {
182+
interface Window {
183+
multiWordModalData: typeof multiWordModalData;
184+
}
185+
}
186+
187+
window.multiWordModalData = multiWordModalData;
188+
189+
// Also export as default for simpler imports
190+
export default multiWordModalData;

src/frontend/js/reading/components/text_reader.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,9 +166,9 @@ export function textReaderData(): TextReaderData {
166166
event.preventDefault();
167167
event.stopPropagation();
168168

169-
// Get word data from element
170-
const hex = wordEl.dataset.hex;
171-
const position = parseInt(wordEl.dataset.position || wordEl.dataset.order || '0', 10);
169+
// Get word data from element (use getAttribute for underscore attributes)
170+
const hex = wordEl.getAttribute('data_hex') || wordEl.className.match(/TERM([0-9A-F]+)/)?.[1] || '';
171+
const position = parseInt(wordEl.getAttribute('data_order') || wordEl.getAttribute('data_pos') || '0', 10);
172172

173173
if (!hex) return;
174174

0 commit comments

Comments
 (0)