web/src/lib/components/ProfileManager.svelte 5.7 K raw
1
<script>
2
  import { aggregateDigraphs } from '../aggregation';
3
  import { saveProfile, loadProfiles, deleteProfile, exportProfile, importProfile } from '../storage';
4
  import { createEventDispatcher } from 'svelte';
5
6
  export let digraphs = [];
7
  export let metadata = null;
8
9
  const dispatch = createEventDispatcher();
10
11
  let profiles = loadProfiles();
12
  let profileName = '';
13
  let importError = '';
14
  let fileInput;
15
16
  function refreshProfiles() {
17
    profiles = loadProfiles();
18
  }
19
20
  function handleSave() {
21
    const aggregations = aggregateDigraphs(digraphs);
22
    const name = profileName.trim() || `Session - ${new Date().toLocaleDateString()}`;
23
    const profile = {
24
      id: crypto.randomUUID(),
25
      name,
26
      createdAt: new Date().toISOString(),
27
      digraphCount: digraphs.length,
28
      aggregations,
29
      metadata: { ...metadata },
30
    };
31
    saveProfile(profile);
32
    profileName = '';
33
    refreshProfiles();
34
    dispatch('saved');
35
  }
36
37
  function handleDelete(id) {
38
    deleteProfile(id);
39
    refreshProfiles();
40
  }
41
42
  function handleExport(profile) {
43
    exportProfile(profile);
44
  }
45
46
  async function handleImport(e) {
47
    importError = '';
48
    const file = e.target.files?.[0];
49
    if (!file) return;
50
    try {
51
      const profile = await importProfile(file);
52
      saveProfile(profile);
53
      refreshProfiles();
54
    } catch (err) {
55
      importError = err.message;
56
    }
57
    // Reset file input so the same file can be re-imported
58
    if (fileInput) fileInput.value = '';
59
  }
60
61
  function formatDate(iso) {
62
    return new Date(iso).toLocaleDateString(undefined, {
63
      month: 'short',
64
      day: 'numeric',
65
      year: 'numeric',
66
      hour: '2-digit',
67
      minute: '2-digit',
68
    });
69
  }
70
</script>
71
72
<div class="profile-manager">
73
  <div class="save-section">
74
    <div class="save-row">
75
      <input
76
        type="text"
77
        bind:value={profileName}
78
        placeholder="profile name"
79
        class="name-input"
80
      />
81
      <button
82
        on:click={handleSave}
83
        disabled={digraphs.length === 0}
84
        class="save-btn"
85
      >
86
        save profile
87
      </button>
88
    </div>
89
    {#if digraphs.length === 0}
90
      <span class="hint">capture some digraphs first</span>
91
    {/if}
92
  </div>
93
94
  <div class="profiles-section">
95
    <div class="section-header">
96
      <span class="section-title">saved profiles ({profiles.length})</span>
97
      <button class="import-btn" on:click={() => fileInput.click()}>
98
        import
99
      </button>
100
      <input
101
        type="file"
102
        accept=".json"
103
        bind:this={fileInput}
104
        on:change={handleImport}
105
        class="hidden-input"
106
      />
107
    </div>
108
109
    {#if importError}
110
      <span class="error">{importError}</span>
111
    {/if}
112
113
    {#if profiles.length === 0}
114
      <div class="empty-profiles">
115
        <span>no saved profiles</span>
116
      </div>
117
    {:else}
118
      <div class="profiles-list">
119
        {#each profiles as profile (profile.id)}
120
          <div class="profile-row">
121
            <div class="profile-info">
122
              <span class="profile-name">{profile.name}</span>
123
              <span class="profile-meta">
124
                {formatDate(profile.createdAt)} · {profile.aggregations.length} pairs · {profile.digraphCount} digraphs
125
              </span>
126
            </div>
127
            <div class="profile-actions">
128
              <button class="action-btn" on:click={() => handleExport(profile)}>
129
                export
130
              </button>
131
              <button class="action-btn delete-btn" on:click={() => handleDelete(profile.id)}>
132
                delete
133
              </button>
134
            </div>
135
          </div>
136
        {/each}
137
      </div>
138
    {/if}
139
  </div>
140
</div>
141
142
<style>
143
  .profile-manager {
144
    display: flex;
145
    flex-direction: column;
146
    gap: 1.5rem;
147
  }
148
149
  .save-section {
150
    display: flex;
151
    flex-direction: column;
152
    gap: 0.35rem;
153
  }
154
155
  .save-row {
156
    display: flex;
157
    gap: 0.5rem;
158
  }
159
160
  .name-input {
161
    flex: 1;
162
    font-size: 12px;
163
    padding: 6px 8px;
164
  }
165
166
  .save-btn {
167
    font-size: 12px;
168
    padding: 6px 12px;
169
    white-space: nowrap;
170
  }
171
172
  .save-btn:disabled {
173
    opacity: 0.3;
174
    cursor: not-allowed;
175
  }
176
177
  .hint {
178
    font-size: 11px;
179
    color: #555;
180
  }
181
182
  .section-header {
183
    display: flex;
184
    align-items: center;
185
    gap: 0.5rem;
186
  }
187
188
  .section-title {
189
    font-size: 12px;
190
    color: #888;
191
    flex: 1;
192
  }
193
194
  .import-btn {
195
    font-size: 11px;
196
    padding: 3px 8px;
197
  }
198
199
  .hidden-input {
200
    display: none;
201
  }
202
203
  .error {
204
    font-size: 11px;
205
    color: #ff6b6b;
206
  }
207
208
  .empty-profiles {
209
    display: flex;
210
    align-items: center;
211
    justify-content: center;
212
    padding: 2rem;
213
    border: 1px dashed #333;
214
    font-size: 12px;
215
    color: #555;
216
  }
217
218
  .profiles-list {
219
    display: flex;
220
    flex-direction: column;
221
    border: 1px solid #333;
222
  }
223
224
  .profile-row {
225
    display: flex;
226
    align-items: center;
227
    justify-content: space-between;
228
    padding: 0.6rem 0.75rem;
229
    border-bottom: 1px solid #333;
230
    gap: 0.75rem;
231
  }
232
233
  .profile-row:last-child {
234
    border-bottom: none;
235
  }
236
237
  .profile-info {
238
    display: flex;
239
    flex-direction: column;
240
    gap: 0.15rem;
241
    min-width: 0;
242
  }
243
244
  .profile-name {
245
    font-size: 13px;
246
    font-weight: 700;
247
    overflow: hidden;
248
    text-overflow: ellipsis;
249
    white-space: nowrap;
250
  }
251
252
  .profile-meta {
253
    font-size: 10px;
254
    color: #888;
255
  }
256
257
  .profile-actions {
258
    display: flex;
259
    gap: 0.35rem;
260
    flex-shrink: 0;
261
  }
262
263
  .action-btn {
264
    font-size: 11px;
265
    padding: 3px 8px;
266
  }
267
268
  .delete-btn {
269
    border-color: #555;
270
    color: #888;
271
  }
272
273
  .delete-btn:hover {
274
    border-color: #ff6b6b;
275
    color: #ff6b6b;
276
    opacity: 1;
277
  }
278
279
  @media (max-width: 480px) {
280
    .profile-row {
281
      flex-direction: column;
282
      align-items: flex-start;
283
      gap: 0.5rem;
284
    }
285
286
    .profile-actions {
287
      align-self: flex-end;
288
    }
289
  }
290
</style>