chore: implemented session storage 0a98f536
Steve · 2026-03-20 07:16 3 file(s) · +361 −0
src/App.svelte +2 −0
3 3
  import TabBar from './lib/components/TabBar.svelte';
4 4
  import CapturePanel from './lib/components/CapturePanel.svelte';
5 5
  import ProfileSummary from './lib/components/ProfileSummary.svelte';
6 +
  import ProfileManager from './lib/components/ProfileManager.svelte';
6 7
7 8
  let pendingKeys = new Map();
8 9
  let lastCompleted = null;
136 137
    {/if}
137 138
  {:else if activeTab === 'profile'}
138 139
    <ProfileSummary {digraphs} metadata={sessionMetadata} />
140 +
    <ProfileManager {digraphs} metadata={sessionMetadata} />
139 141
  {:else if activeTab === 'compare'}
140 142
    <div class="placeholder">
141 143
      <p>session comparison</p>
src/lib/components/ProfileManager.svelte (added) +290 −0
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>
src/lib/storage.ts (added) +69 −0
1 +
import type { Profile } from './types';
2 +
3 +
const STORAGE_KEY = 'kd_profiles';
4 +
5 +
export function loadProfiles(): Profile[] {
6 +
  try {
7 +
    const raw = localStorage.getItem(STORAGE_KEY);
8 +
    if (!raw) return [];
9 +
    const parsed = JSON.parse(raw);
10 +
    if (!Array.isArray(parsed)) return [];
11 +
    return parsed.filter(
12 +
      (p: any) =>
13 +
        p &&
14 +
        typeof p.id === 'string' &&
15 +
        typeof p.name === 'string' &&
16 +
        Array.isArray(p.aggregations),
17 +
    );
18 +
  } catch {
19 +
    return [];
20 +
  }
21 +
}
22 +
23 +
export function saveProfile(profile: Profile): void {
24 +
  const profiles = loadProfiles();
25 +
  profiles.push(profile);
26 +
  localStorage.setItem(STORAGE_KEY, JSON.stringify(profiles));
27 +
}
28 +
29 +
export function deleteProfile(id: string): void {
30 +
  const profiles = loadProfiles().filter((p) => p.id !== id);
31 +
  localStorage.setItem(STORAGE_KEY, JSON.stringify(profiles));
32 +
}
33 +
34 +
export function exportProfile(profile: Profile): void {
35 +
  const json = JSON.stringify(profile, null, 2);
36 +
  const blob = new Blob([json], { type: 'application/json' });
37 +
  const url = URL.createObjectURL(blob);
38 +
  const a = document.createElement('a');
39 +
  a.href = url;
40 +
  a.download = `${profile.name.replace(/[^a-z0-9_-]/gi, '_')}.json`;
41 +
  a.click();
42 +
  URL.revokeObjectURL(url);
43 +
}
44 +
45 +
export function importProfile(file: File): Promise<Profile> {
46 +
  return new Promise((resolve, reject) => {
47 +
    const reader = new FileReader();
48 +
    reader.onload = () => {
49 +
      try {
50 +
        const profile = JSON.parse(reader.result as string);
51 +
        if (
52 +
          !profile ||
53 +
          typeof profile.name !== 'string' ||
54 +
          !Array.isArray(profile.aggregations)
55 +
        ) {
56 +
          reject(new Error('Invalid profile format'));
57 +
          return;
58 +
        }
59 +
        // Assign a new ID to avoid collisions
60 +
        profile.id = crypto.randomUUID();
61 +
        resolve(profile as Profile);
62 +
      } catch {
63 +
        reject(new Error('Failed to parse profile JSON'));
64 +
      }
65 +
    };
66 +
    reader.onerror = () => reject(new Error('Failed to read file'));
67 +
    reader.readAsText(file);
68 +
  });
69 +
}