diff --git a/.github/workflows/fw-lite.yaml b/.github/workflows/fw-lite.yaml index 4fc814f76c..282bbb060f 100644 --- a/.github/workflows/fw-lite.yaml +++ b/.github/workflows/fw-lite.yaml @@ -21,6 +21,7 @@ on: branches: - develop - main + - shadcn-ui-main #just for now ensure PRs to this branch have checks run env: VIEWER_BUILD_OUTPUT_DIR: backend/FwLite/FwLiteShared/wwwroot/viewer jobs: diff --git a/backend/FwLite/FwLiteShared/Events/EntryDeletedEvent.cs b/backend/FwLite/FwLiteShared/Events/EntryDeletedEvent.cs new file mode 100644 index 0000000000..62d34b4545 --- /dev/null +++ b/backend/FwLite/FwLiteShared/Events/EntryDeletedEvent.cs @@ -0,0 +1,8 @@ +namespace FwLiteShared.Events; + +public class EntryDeletedEvent(Guid entryId) : IFwEvent +{ + public FwEventType Type => FwEventType.EntryDeleted; + public bool IsGlobal => false; + public Guid EntryId { get; init; } = entryId; +} diff --git a/backend/FwLite/FwLiteShared/Events/IFwEvent.cs b/backend/FwLite/FwLiteShared/Events/IFwEvent.cs index 57dafce52f..745b3f7ad9 100644 --- a/backend/FwLite/FwLiteShared/Events/IFwEvent.cs +++ b/backend/FwLite/FwLiteShared/Events/IFwEvent.cs @@ -4,6 +4,7 @@ namespace FwLiteShared.Events; [JsonPolymorphic] [JsonDerivedType(typeof(EntryChangedEvent), nameof(EntryChangedEvent))] +[JsonDerivedType(typeof(EntryDeletedEvent), nameof(EntryDeletedEvent))] [JsonDerivedType(typeof(ProjectEvent), nameof(ProjectEvent))] [JsonDerivedType(typeof(AuthenticationChangedEvent), nameof(AuthenticationChangedEvent))] public interface IFwEvent @@ -19,4 +20,5 @@ public enum FwEventType EntryChanged, AuthenticationChanged, ProjectEvent, + EntryDeleted } diff --git a/backend/FwLite/FwLiteShared/Pages/CrdtProject.razor b/backend/FwLite/FwLiteShared/Pages/CrdtProject.razor index 2bcad7a057..465372cc8a 100644 --- a/backend/FwLite/FwLiteShared/Pages/CrdtProject.razor +++ b/backend/FwLite/FwLiteShared/Pages/CrdtProject.razor @@ -1,8 +1,10 @@ -@page "/project/{projectName}" +@page "/project/{projectName}/{*path}" @using FwLiteShared.Layout @layout SvelteLayout; @code { [Parameter] public required string ProjectName { get; set; } + [Parameter] + public required string Path { get; set; } } diff --git a/backend/FwLite/FwLiteShared/Pages/FwdataProject.razor b/backend/FwLite/FwLiteShared/Pages/FwdataProject.razor index 3bd8d71882..6589dcdfc5 100644 --- a/backend/FwLite/FwLiteShared/Pages/FwdataProject.razor +++ b/backend/FwLite/FwLiteShared/Pages/FwdataProject.razor @@ -1,4 +1,4 @@ -@page "/fwdata/{projectName}" +@page "/fwdata/{projectName}/{*path}" @using FwLiteShared.Layout @layout SvelteLayout; @@ -6,5 +6,7 @@ [Parameter] public required string ProjectName { get; set; } + [Parameter] + public required string Path { get; set; } } diff --git a/backend/FwLite/FwLiteShared/Pages/Sandbox.razor b/backend/FwLite/FwLiteShared/Pages/Sandbox.razor index f096aa8553..c3fbf33e7e 100644 --- a/backend/FwLite/FwLiteShared/Pages/Sandbox.razor +++ b/backend/FwLite/FwLiteShared/Pages/Sandbox.razor @@ -1,6 +1,11 @@ -@page "/sandbox" +@page "/sandbox/{*path}" @using FwLiteShared.Layout @using FwLiteShared.Services @layout SvelteLayout; -@*this looks empty because it is, but it's required to declare the route which is then used by the svelte router*@ +@code { + + [Parameter] + public required string Path { get; set; } + +} diff --git a/backend/FwLite/FwLiteShared/Pages/TestingProject.razor b/backend/FwLite/FwLiteShared/Pages/TestingProject.razor index 8c182a2e03..a206ba7bae 100644 --- a/backend/FwLite/FwLiteShared/Pages/TestingProject.razor +++ b/backend/FwLite/FwLiteShared/Pages/TestingProject.razor @@ -1,3 +1,10 @@ -@page "/testing/project-view" +@page "/testing/project-view/{*path}" @using FwLiteShared.Layout @layout SvelteLayout; + +@code { + + [Parameter] + public required string Path { get; set; } + +} diff --git a/backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs b/backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs index e1a72d5fe3..df922b2547 100644 --- a/backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs +++ b/backend/FwLite/FwLiteShared/Services/MiniLcmApiNotifyWrapper.cs @@ -72,6 +72,11 @@ public async Task NotifyEntryChangedAsync(Guid entryId) bus.PublishEntryChangedEvent(project, entry); } + public void NotifyEntryDeleted(Guid entryId) + { + bus.PublishEvent(project, new EntryDeletedEvent(entryId)); + } + // ********** Overrides go here ********** async Task IMiniLcmWriteApi.CreateEntry(Entry entry) @@ -105,6 +110,24 @@ async Task IMiniLcmWriteApi.DeleteComplexFormComponent(ComplexFormComponent comp await NotifyEntryChangedAsync(complexFormComponent.ComponentEntryId); } + async Task IMiniLcmWriteApi.DeleteEntry(Guid id) + { + await _api.DeleteEntry(id); + NotifyEntryDeleted(id); + } + + async Task IMiniLcmWriteApi.DeleteSense(Guid entryId, Guid senseId) + { + await _api.DeleteSense(entryId, senseId); + await NotifyEntryChangedAsync(entryId); + } + + async Task IMiniLcmWriteApi.DeleteExampleSentence(Guid entryId, Guid senseId, Guid exampleSentenceId) + { + await _api.DeleteExampleSentence(entryId, senseId, exampleSentenceId); + await NotifyEntryChangedAsync(entryId); + } + void IDisposable.Dispose() { } diff --git a/backend/FwLite/FwLiteShared/Sync/SyncService.cs b/backend/FwLite/FwLiteShared/Sync/SyncService.cs index 3f7a3c6cea..fcb27a03a0 100644 --- a/backend/FwLite/FwLiteShared/Sync/SyncService.cs +++ b/backend/FwLite/FwLiteShared/Sync/SyncService.cs @@ -10,6 +10,7 @@ using MiniLcm; using MiniLcm.Models; using SIL.Harmony; +using SIL.Harmony.Changes; namespace FwLiteShared.Sync; @@ -95,6 +96,13 @@ private async Task SendNotifications(SyncResults syncResults) logger.LogError("Failed to get entry {EntryId}, was not found", entryId); } } + + foreach (var deleteChange in syncResults.MissingFromLocal + .SelectMany(c => c.ChangeEntities, (_, change) => change.Change) + .OfType>()) + { + changeEventBus.PublishEvent(currentProjectService.Project, new EntryDeletedEvent(deleteChange.EntityId)); + } } catch (Exception e) { diff --git a/backend/FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs b/backend/FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs index f60ad37d19..697d60d8a3 100644 --- a/backend/FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs +++ b/backend/FwLite/FwLiteWeb/Routes/FwIntegrationRoutes.cs @@ -20,7 +20,7 @@ public static IEndpointConventionBuilder MapFwIntegrationRoutes(this WebApplicat }); return operation; }); - group.MapGet("/open/entry/{id}", + group.MapGet("/link/entry/{id}", async ([FromServices] FwDataProjectContext context, [FromServices] IHubContext hubContext, [FromServices] FwDataFactory factory, @@ -29,8 +29,8 @@ public static IEndpointConventionBuilder MapFwIntegrationRoutes(this WebApplicat if (context.Project is null) return Results.BadRequest("No project is set in the context"); await hubContext.Clients.Group(context.Project.Name).OnProjectClosed(CloseReason.Locked); factory.CloseProject(context.Project); - //need to use redirect as a way to not trigger flex until after we have closed the project - return Results.Redirect(FwLink.ToEntry(id, context.Project.Name)); + var link = FwLink.ToEntry(id, context.Project.Name); + return Results.Text(link, "text/plain"); }); return group; } diff --git a/frontend/Taskfile.yml b/frontend/Taskfile.yml index 80682d78c8..abfa34d994 100644 --- a/frontend/Taskfile.yml +++ b/frontend/Taskfile.yml @@ -31,7 +31,8 @@ tasks: - task: fw-lite-infr-internal fw-lite-infr-internal: - deps: [ viewer-dev, https-oauth-authority ] + internal: true + deps: [ viewer:run-app, run-https-proxy ] playwright-tests: aliases: [ pt ] @@ -80,6 +81,9 @@ tasks: cmds: - corepack enable || true - pnpm install + run-https-proxy: + dir: ./https-proxy + cmd: pnpm run dev https-proxy: dir: ./https-proxy desc: "MSAL requires the oauth authority to be available over https. That's why this is here. As a bonus it dynamically looks for the UI either locally or in k8s." diff --git a/frontend/package.json b/frontend/package.json index a4c5877a58..d79d09a51c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,7 +38,7 @@ "@graphql-codegen/typescript": "^4.1.6", "@graphql-typed-document-node/core": "^3.2.0", "@iconify-json/mdi": "^1.2.3", - "@playwright/test": "^1.52.0", + "@playwright/test": "catalog:", "@stylistic/eslint-plugin": "catalog:", "@sveltejs/adapter-node": "^5.2.12", "@sveltejs/kit": "2.20.7", @@ -102,7 +102,7 @@ "js-cookie": "^3.0.5", "just-order-by": "^1.0.0", "mjml": "^4.15.3", - "runed": "^0.26.0", + "runed": "catalog:", "set-cookie-parser": "^2.7.1", "svelte-exmarkdown": "catalog:", "svelte-intl-precompile": "^0.12.3", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5f6b1edbe6..ee787e95ba 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -6,6 +6,9 @@ settings: catalogs: default: + '@playwright/test': + specifier: ^1.49.1 + version: 1.50.1 '@stylistic/eslint-plugin': specifier: ^2.12.1 version: 2.13.0 @@ -33,6 +36,9 @@ catalogs: postcss: specifier: ^8.4.49 version: 8.5.2 + runed: + specifier: ^0.27.0 + version: 0.27.0 svelte: specifier: ^5.28.0 version: 5.28.2 @@ -132,8 +138,8 @@ importers: specifier: ^4.15.3 version: 4.15.3 runed: - specifier: ^0.26.0 - version: 0.26.0(svelte@5.28.2) + specifier: 'catalog:' + version: 0.27.0(svelte@5.28.2) set-cookie-parser: specifier: ^2.7.1 version: 2.7.1 @@ -181,8 +187,8 @@ importers: specifier: ^1.2.3 version: 1.2.3 '@playwright/test': - specifier: ^1.52.0 - version: 1.52.0 + specifier: 'catalog:' + version: 1.50.1 '@stylistic/eslint-plugin': specifier: 'catalog:' version: 2.13.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3) @@ -355,8 +361,8 @@ importers: specifier: ^1.38.1 version: 1.38.1 runed: - specifier: ^0.24.1 - version: 0.24.1(svelte@5.28.2) + specifier: 'catalog:' + version: 0.27.0(svelte@5.28.2) svelte-dnd-action: specifier: ^0.9.57 version: 0.9.57(svelte@5.28.2) @@ -397,6 +403,9 @@ importers: '@mdi/js': specifier: ^7.4.47 version: 7.4.47 + '@playwright/test': + specifier: 'catalog:' + version: 1.50.1 '@stylistic/eslint-plugin': specifier: 'catalog:' version: 2.13.0(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3) @@ -431,8 +440,8 @@ importers: specifier: 'catalog:' version: 3.0.5(vitest@3.0.5) bits-ui: - specifier: 1.3.16 - version: 1.3.16(svelte@5.28.2) + specifier: 1.4.3 + version: 1.4.3(svelte@5.28.2) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -446,8 +455,8 @@ importers: specifier: ^17.1.0 version: 17.1.0 mode-watcher: - specifier: ^0.5.1 - version: 0.5.1(svelte@5.28.2) + specifier: ^1.0.7 + version: 1.0.7(svelte@5.28.2) paneforge: specifier: 1.0.0-next.4 version: 1.0.0-next.4(svelte@5.28.2) @@ -2052,8 +2061,8 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@playwright/test@1.52.0': - resolution: {integrity: sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==} + '@playwright/test@1.50.1': + resolution: {integrity: sha512-Jii3aBg+CEDpgnuDxEp/h7BimHcUTDlpEtce89xEumlJ5ef2hqepZ+PWp1DDpYC/VO9fmWVI1IlEaoI5fK9FXQ==} engines: {node: '>=18'} hasBin: true @@ -2854,8 +2863,8 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} - bits-ui@1.3.16: - resolution: {integrity: sha512-eBifcu68EspZ1n7mpiyCEjbNAaPoaWLuQZ4aw4lYFt4kJYsOXeAiwKS3+IXwDvYZ9NzBDSSrWbst2k6kKU+8SQ==} + bits-ui@1.4.3: + resolution: {integrity: sha512-7D85ZBbUgf/ihL2to5McGs0oGZo0T274NX2Aejak+Ycj69jSqAvGPyTEFkKdf0NMZIxuo5Fz4c4jrljPvpUpMg==} engines: {node: '>=18', pnpm: '>=8.7.0'} peerDependencies: svelte: ^5.11.0 @@ -4685,10 +4694,10 @@ packages: mlly@1.7.4: resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==} - mode-watcher@0.5.1: - resolution: {integrity: sha512-adEC6T7TMX/kzQlaO/MtiQOSFekZfQu4MC+lXyoceQG+U5sKpJWZ4yKXqw846ExIuWJgedkOIPqAYYRk/xHm+w==} + mode-watcher@1.0.7: + resolution: {integrity: sha512-ZGA7ZGdOvBJeTQkzdBOnXSgTkO6U6iIFWJoyGCTt6oHNg9XP9NBvS26De+V4W2aqI+B0yYXUskFG2VnEo3zyMQ==} peerDependencies: - svelte: ^4.0.0 || ^5.0.0-next.1 + svelte: ^5.27.0 module-details-from-path@1.0.3: resolution: {integrity: sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==} @@ -4987,13 +4996,13 @@ packages: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} - playwright-core@1.52.0: - resolution: {integrity: sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==} + playwright-core@1.50.1: + resolution: {integrity: sha512-ra9fsNWayuYumt+NiM069M6OkcRb1FZSK8bgi66AtpFoWkg2+y0bJSNmkFrWhMbEBbVKC/EruAHH3g0zmtwGmQ==} engines: {node: '>=18'} hasBin: true - playwright@1.52.0: - resolution: {integrity: sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==} + playwright@1.50.1: + resolution: {integrity: sha512-G8rwsOQJ63XG6BbKj2w5rHeavFjy5zynBA9zsJMMtBoe/Uf757oG12NXz6e6OirF7RCrTVAKFXbLmn1RbL7Qaw==} engines: {node: '>=18'} hasBin: true @@ -5330,13 +5339,13 @@ packages: peerDependencies: svelte: ^5.7.0 - runed@0.24.1: - resolution: {integrity: sha512-7LWRiZ+TR/NKSkBwO/AQXgCR40dLl/toM3DxlagPSxXsRGGi40hQkKt2S5ESUrgZJ7YbXhct/vlnJwp2oRrGCg==} + runed@0.25.0: + resolution: {integrity: sha512-7+ma4AG9FT2sWQEA0Egf6mb7PBT2vHyuHail1ie8ropfSjvZGtEAx8YTmUjv/APCsdRRxEVvArNjALk9zFSOrg==} peerDependencies: svelte: ^5.7.0 - runed@0.26.0: - resolution: {integrity: sha512-qWFv0cvLVRd8pdl/AslqzvtQyEn5KaIugEernwg9G98uJVSZcs/ygvPBvF80LA46V8pwRvSKnaVLDI3+i2wubw==} + runed@0.27.0: + resolution: {integrity: sha512-Iwn06Yj7tOnN8UKaSYxY1m7lqig03ZUOlZnZJJ6jDvBgcaxSlrDoWL2v/14yvoE8xFdI9ljdut3DwvzpZbz3/w==} peerDependencies: svelte: ^5.7.0 @@ -8256,9 +8265,9 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@playwright/test@1.52.0': + '@playwright/test@1.50.1': dependencies: - playwright: 1.52.0 + playwright: 1.50.1 '@polka/url@1.0.0-next.28': {} @@ -9161,7 +9170,7 @@ snapshots: binary-extensions@2.3.0: {} - bits-ui@1.3.16(svelte@5.28.2): + bits-ui@1.4.3(svelte@5.28.2): dependencies: '@floating-ui/core': 1.6.9 '@floating-ui/dom': 1.6.13 @@ -11489,9 +11498,11 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.1 - mode-watcher@0.5.1(svelte@5.28.2): + mode-watcher@1.0.7(svelte@5.28.2): dependencies: + runed: 0.25.0(svelte@5.28.2) svelte: 5.28.2 + svelte-toolbelt: 0.7.1(svelte@5.28.2) module-details-from-path@1.0.3: {} @@ -11774,11 +11785,11 @@ snapshots: dependencies: find-up: 3.0.0 - playwright-core@1.52.0: {} + playwright-core@1.50.1: {} - playwright@1.52.0: + playwright@1.50.1: dependencies: - playwright-core: 1.52.0 + playwright-core: 1.50.1 optionalDependencies: fsevents: 2.3.2 @@ -12141,12 +12152,12 @@ snapshots: esm-env: 1.2.2 svelte: 5.28.2 - runed@0.24.1(svelte@5.28.2): + runed@0.25.0(svelte@5.28.2): dependencies: esm-env: 1.2.2 svelte: 5.28.2 - runed@0.26.0(svelte@5.28.2): + runed@0.27.0(svelte@5.28.2): dependencies: esm-env: 1.2.2 svelte: 5.28.2 diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml index 80fc2c36db..109d28515a 100644 --- a/frontend/pnpm-workspace.yaml +++ b/frontend/pnpm-workspace.yaml @@ -22,3 +22,5 @@ catalog: "svelte-eslint-parser": ^1.0.0-next.0 "@stylistic/eslint-plugin": ^2.12.1 "@vitejs/plugin-basic-ssl": ^1.2.0 + "@playwright/test": ^1.49.1 + runed: ^0.27.0 diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/viewer/+page.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/viewer/+page.svelte index 6e70a50133..9ea1a072e8 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/viewer/+page.svelte +++ b/frontend/src/routes/(authenticated)/project/[project_code]/viewer/+page.svelte @@ -1,10 +1,7 @@ {#if service} {#key service} - + {/key} {/if} diff --git a/frontend/viewer/.gitignore b/frontend/viewer/.gitignore index 20bd81ce59..cc479eec64 100644 --- a/frontend/viewer/.gitignore +++ b/frontend/viewer/.gitignore @@ -23,3 +23,4 @@ dist-ssr *.njsproj *.sln *.sw? +html-test-results diff --git a/frontend/viewer/README.md b/frontend/viewer/README.md index dd0114327d..9907fae748 100644 --- a/frontend/viewer/README.md +++ b/frontend/viewer/README.md @@ -22,3 +22,10 @@ for formatted values you can do this: ```sveltehtml {$t`Hello ${name}, how are you today?`} ``` + +### ShadCN + +add a new component +```bash +pnpx shadcn-svelte@next add context-menu +``` diff --git a/frontend/viewer/Taskfile.yml b/frontend/viewer/Taskfile.yml index c6415fd4a6..d2c867d673 100644 --- a/frontend/viewer/Taskfile.yml +++ b/frontend/viewer/Taskfile.yml @@ -5,6 +5,9 @@ tasks: interactive: true deps: [ install ] cmd: pnpm run dev-app + run-app: + interactive: true + cmd: pnpm run dev-app web-component: aliases: [ wc ] interactive: true @@ -13,6 +16,7 @@ tasks: check: aliases: [ sc, svelte-check ] deps: [ install ] + ignore_error: true cmds: - pnpm run --filter viewer check - pnpm run --filter viewer lint @@ -34,3 +38,14 @@ tasks: - pnpm install test: cmd: pnpm test + + playwright-test: + desc: 'runs playwright tests against already running server' + cmd: pnpm run test:playwright {{.CLI_ARGS}} + playwright-test-standalone: + desc: 'runs playwright tests and runs dev-app automatically, run ui mode by calling with -- --ui or use --update-snapshots' + env: + AUTO_START_SERVER: true + cmd: pnpm run test:playwright {{.CLI_ARGS}} + playwright-test-report: + cmd: pnpm run test:playwright-report diff --git a/frontend/viewer/eslint.config.js b/frontend/viewer/eslint.config.js index ed2c99a33d..f14c3adaa1 100644 --- a/frontend/viewer/eslint.config.js +++ b/frontend/viewer/eslint.config.js @@ -118,7 +118,7 @@ export default [ parser: tsParser, parserOptions: { projectService: { - allowDefaultProject: ['lingui.config.ts', 'tailwind.config.ts'], + allowDefaultProject: ['lingui.config.ts', 'tailwind.config.ts', 'playwright.config.ts'], }, tsconfigRootDir: __dirname, extraFileExtensions: ['.svelte'], // Yes, TS-Parser, relax when you're fed svelte files diff --git a/frontend/viewer/index.html b/frontend/viewer/index.html index 6ea52d850c..4923d562ff 100644 --- a/frontend/viewer/index.html +++ b/frontend/viewer/index.html @@ -1,9 +1,9 @@ - - - - + + + + FieldWorks Lite - - -
- -
- - + + + +
+ +
+ + diff --git a/frontend/viewer/package.json b/frontend/viewer/package.json index 3c7b2d668a..2ec9d6d7b9 100644 --- a/frontend/viewer/package.json +++ b/frontend/viewer/package.json @@ -21,6 +21,10 @@ "build": "vite build -m web-component", "build-app": "vite build", "preview": "vite preview", + "pretest:playwright": "playwright install", + "test:playwright": "playwright test", + "test:playwright-report": "playwright show-report html-test-results", + "test:playwright-record": "playwright codegen", "test": "vitest run", "test:ui": "vitest --ui", "test:watch": "vitest", @@ -36,6 +40,7 @@ "@lingui/format-json": "^5.2.0", "@lingui/vite-plugin": "^5.2.0", "@mdi/js": "^7.4.47", + "@playwright/test": "catalog:", "@stylistic/eslint-plugin": "catalog:", "@sveltejs/vite-plugin-svelte": "catalog:", "@tailwindcss/container-queries": "^0.1.1", @@ -48,12 +53,12 @@ "@typescript-eslint/parser": "catalog:", "@vitest/ui": "catalog:", "autoprefixer": "^10.4.20", - "bits-ui": "1.3.16", + "bits-ui": "1.4.3", "clsx": "^2.1.1", "eslint": "catalog:", "eslint-plugin-svelte": "catalog:", "happy-dom": "^17.1.0", - "mode-watcher": "^0.5.1", + "mode-watcher": "^1.0.7", "paneforge": "1.0.0-next.4", "svelte": "catalog:", "svelte-check": "catalog:", @@ -85,7 +90,7 @@ "prosemirror-model": "^1.25.0", "prosemirror-state": "^1.4.3", "prosemirror-view": "^1.38.1", - "runed": "^0.24.1", + "runed": "catalog:", "svelte-dnd-action": "^0.9.57", "svelte-exmarkdown": "catalog:", "svelte-i18n-lingui": "^0.2.2", diff --git a/frontend/viewer/playwright.config.ts b/frontend/viewer/playwright.config.ts new file mode 100644 index 0000000000..1debeffd73 --- /dev/null +++ b/frontend/viewer/playwright.config.ts @@ -0,0 +1,64 @@ +import {defineConfig, devices} from '@playwright/test'; +import * as testEnv from '../tests/envVars'; +const vitePort = '5173'; +const dotnetPort = '5137'; +const autoStartServer = process.env.AUTO_START_SERVER ? Boolean(process.env.AUTO_START_SERVER) : false; +const serverPort = process.env.SERVER_PORT ?? (autoStartServer ? vitePort : dotnetPort); +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 1 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : 2, + outputDir: 'test-results', + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: process.env.CI + ? [['github'], ['list'], ['junit', {outputFile: 'test-results/results.xml'}]] + // Putting the HTML report in a subdirectory of the main output directory results in a warning log + // stating that it will "lead to artifact loss" but the warning in this case is not accurate + : [['list'], ['html', {outputFolder: 'html-test-results', open: 'never'}]], + + use: { + baseURL: 'http://localhost:' + serverPort, + /* Local storage to be populated for every test */ + storageState: { + cookies: [], + origins: [ + { + origin: testEnv.serverHostname, + localStorage: [ + { + name: 'isPlaywright', + value: 'true', + }, + { + name: 'shadcnMode', + value: 'true' + + } + ], + }, + ], + }, + viewport: { + height: 720, + width: 1280, + } + }, + webServer: [ + { + command: 'pnpm run dev-app', + url: 'http://localhost:5173', + reuseExistingServer: true + } + ], + projects: [ + { + name: 'chromium', + use: {...devices['Desktop Chrome'], userAgent: 'Playwright Chrome'}, + }, + ] +}); diff --git a/frontend/viewer/src/App.svelte b/frontend/viewer/src/App.svelte index a4d41e7b33..7735a3c790 100644 --- a/frontend/viewer/src/App.svelte +++ b/frontend/viewer/src/App.svelte @@ -1,4 +1,4 @@ - - - - -
- + + + +
+ + {#key params.code} {/key} - + {#key params.name} {/key} - + @@ -104,6 +104,6 @@ {setTimeout(() => navigate('/', {replace: true}))} -
- - + + +
diff --git a/frontend/viewer/src/CrdtProjectView.svelte b/frontend/viewer/src/CrdtProjectView.svelte index b73fc8455d..5cbbeb0e3a 100644 --- a/frontend/viewer/src/CrdtProjectView.svelte +++ b/frontend/viewer/src/CrdtProjectView.svelte @@ -6,5 +6,5 @@ - + diff --git a/frontend/viewer/src/DotnetProjectView.svelte b/frontend/viewer/src/DotnetProjectView.svelte index b771965620..2ca244915d 100644 --- a/frontend/viewer/src/DotnetProjectView.svelte +++ b/frontend/viewer/src/DotnetProjectView.svelte @@ -1,7 +1,7 @@  - - - - + diff --git a/frontend/viewer/src/FwDataProjectView.svelte b/frontend/viewer/src/FwDataProjectView.svelte index 46acbdcff3..14f30d5fa7 100644 --- a/frontend/viewer/src/FwDataProjectView.svelte +++ b/frontend/viewer/src/FwDataProjectView.svelte @@ -44,5 +44,5 @@ - + diff --git a/frontend/viewer/src/FwDataWebComponent.svelte b/frontend/viewer/src/FwDataWebComponent.svelte index 4b16fa2a85..ed615bda4a 100644 --- a/frontend/viewer/src/FwDataWebComponent.svelte +++ b/frontend/viewer/src/FwDataWebComponent.svelte @@ -1,6 +1,6 @@  @@ -43,6 +41,6 @@ {css} -
+
diff --git a/frontend/viewer/src/ProjectLoader.svelte b/frontend/viewer/src/ProjectLoader.svelte index a6f1aa2d8e..fa3863da08 100644 --- a/frontend/viewer/src/ProjectLoader.svelte +++ b/frontend/viewer/src/ProjectLoader.svelte @@ -11,9 +11,9 @@ {#if loading} -
+
- Loading {projectName}... + Loading {projectName}...
{/if} diff --git a/frontend/viewer/src/ProjectView.svelte b/frontend/viewer/src/ProjectView.svelte index ff88b76c92..6c0fbf8390 100644 --- a/frontend/viewer/src/ProjectView.svelte +++ b/frontend/viewer/src/ProjectView.svelte @@ -1,35 +1,17 @@ -{#if $useShadcn || $isDev} -
- -
-{/if} - -
- onloaded(e.detail)} {projectName} {about} {isConnected} {showHomeButton} /> -
- - - - + diff --git a/frontend/viewer/src/ShadcnProjectView.svelte b/frontend/viewer/src/ShadcnProjectView.svelte index ca2a046923..e6c928cc80 100644 --- a/frontend/viewer/src/ShadcnProjectView.svelte +++ b/frontend/viewer/src/ShadcnProjectView.svelte @@ -14,52 +14,51 @@ - +
- - - {#if currentView === 'browse'} - - {:else if currentView === 'tasks'} - - {/if} - + + + + + + + + + + {setTimeout(() => navigate(`${$base.uri}/browse`, {replace: true}))} + + + Unknown route + +
diff --git a/frontend/viewer/src/SvelteUxProjectView.svelte b/frontend/viewer/src/SvelteUxProjectView.svelte index 2fcf72e9e7..d1d9a1df86 100644 --- a/frontend/viewer/src/SvelteUxProjectView.svelte +++ b/frontend/viewer/src/SvelteUxProjectView.svelte @@ -1,4 +1,4 @@ - - + diff --git a/frontend/viewer/src/WebComponent.svelte b/frontend/viewer/src/WebComponent.svelte index 721f5fd61e..9e38138899 100644 --- a/frontend/viewer/src/WebComponent.svelte +++ b/frontend/viewer/src/WebComponent.svelte @@ -6,16 +6,19 @@ @@ -60,8 +62,8 @@ {css} -
+
- +
diff --git a/frontend/viewer/src/app.postcss b/frontend/viewer/src/app.postcss index e5229f96c2..3815af075f 100644 --- a/frontend/viewer/src/app.postcss +++ b/frontend/viewer/src/app.postcss @@ -16,7 +16,6 @@ @apply block min-h-full; height: max(auto, 100%); scroll-behavior: smooth; - @apply bg-surface-100; interpolate-size: allow-keywords; } @@ -26,7 +25,7 @@ * { @apply border-border; } - body { + .app { @apply bg-background text-foreground; } } @@ -56,7 +55,6 @@ @apply max-md:rounded-none; .actions { - @apply bg-surface-200; position: sticky; bottom: 0; z-index: 2; @@ -75,52 +73,17 @@ scroll-margin-top: 1rem; } -[id^=entry], [id^=sense], .grid-layer:has(> [id^=sense]) { +[id^=entry], [id^=sense], .editor-sub-grid:has(> [id^=sense]) { scroll-margin-bottom: 4rem; } -[id^=example], .grid-layer:has(> [id^=example]) { +[id^=example], .editor-sub-grid:has(> [id^=example]) { /* under the sticky sense header */ scroll-margin-top: 3.5rem; scroll-margin-bottom: 3.5rem; } @layer components { - /* shadcn grid start */ - .sm-form { - grid-template-columns: fit-content(70px) 1fr 1fr; - } - - .lg-form { - grid-template-columns: 180px fit-content(80px) 1fr; - } - - .editor-grid { - display: grid; - grid-template-columns: fit-content(70px) 1fr; - @apply @lg:sm-form; - @apply @3xl:lg-form; - } - - .field-root { - @apply grid grid-cols-subgrid col-span-full items-baseline; - &:not(:last-child) { - @apply mb-4; - } - } - - .field-title { - @apply col-span-full ms-2 me-2 mb-2; - @apply @3xl:col-span-1; - } - - .field-body { - @apply grid grid-cols-subgrid; - @apply col-span-full; - @apply @3xl:col-start-2 @3xl:col-end-4; - } - /* shadcn grid end */ - .collapsible-col { overflow-x: hidden; transition: opacity 0.2s ease-out; @@ -143,7 +106,7 @@ } .text-field-sibling-button { - @apply h-[37.6px] p-1.5 aspect-square text-[0.9em] text-surface-content; + @apply h-[37.6px] p-1.5 aspect-square text-[0.9em]; } .key { @@ -151,7 +114,7 @@ padding: 0.15em 0.4em; margin: 0 0.1em; font-size: 0.8em; - @apply border border-surface-content rounded-md shadow-md; + @apply border rounded-md shadow-md; } .icon-button-group-container { @@ -161,12 +124,6 @@ } } -.grid-layer { - display: grid; - grid-template-columns: subgrid; - @apply col-span-full; -} - .ListItem > * { max-width: 100%; overflow: hidden; diff --git a/frontend/viewer/src/home/HomeView.svelte b/frontend/viewer/src/home/HomeView.svelte index 5f7072c6b4..96e80fc889 100644 --- a/frontend/viewer/src/home/HomeView.svelte +++ b/frontend/viewer/src/home/HomeView.svelte @@ -3,14 +3,11 @@ mdiBookArrowLeftOutline, mdiBookEditOutline, mdiBookPlusOutline, - mdiChatQuestion, mdiChevronRight, mdiDelete, - mdiFaceAgent, - mdiRefresh, mdiTestTube, } from '@mdi/js'; - import {AppBar, Button, ListItem, TextField} from 'svelte-ux'; + import {AppBar, Button as UxButton, ListItem, TextField} from 'svelte-ux'; import flexLogo from '$lib/assets/flex-logo.png'; import logoLight from '$lib/assets/logo-light.svg'; import logoDark from '$lib/assets/logo-dark.svg'; @@ -28,6 +25,10 @@ import LocalizationPicker from '$lib/i18n/LocalizationPicker.svelte'; import ProjectTitle from './ProjectTitle.svelte'; import type {IProjectModel} from '$lib/dotnet-types'; + import ThemePicker from '$lib/ThemePicker.svelte'; + import {Button} from '$lib/components/ui/button'; + import {mode} from 'mode-watcher'; + import * as ResponsiveMenu from '$lib/components/responsive-menu'; const projectsService = useProjectsService(); const importFwdataService = useImportFwdataService(); @@ -97,38 +98,39 @@ } const supportsTroubleshooting = useTroubleshootingService(); - let showTroubleshooting = false; + let troubleshootDialog: TroubleshootDialog | undefined; - +
- - - - {$t`Lexbox - + {$t`Lexbox

{$t`Dictionaries`}

-
- - {#if supportsTroubleshooting} - - - {/if} +
- +
@@ -142,12 +144,15 @@

{$t`Local`}

-
-
+
{#each projects.filter((p) => p.crdt) as project, i (project.id ?? i)} {@const server = project.server} {@const loading = deletingProject === project.id} @@ -156,30 +161,32 @@ icon={mdiBookEditOutline} subheading={!server ? $t`Local only` : $t`Synced with ${server.displayName}`} {loading} + classes={{root: 'dark:bg-muted/50 bg-muted/80 hover:bg-muted/30 hover:dark:bg-muted', subheading: 'text-muted-foreground'}} >
{#if $isDev} -
{/each} - +
-
@@ -189,6 +196,7 @@
{#if $isDev} @@ -198,7 +206,7 @@ on:click={(e) => e.stopPropagation()} /> {/if} -
@@ -209,14 +217,15 @@ {#if projects.some((p) => p.fwdata)}

{$t`Classic FieldWorks Projects`}

-
+
{#each projects.filter((p) => p.fwdata) as project (project.id ?? project.name)} - - + + + {$t`FieldWorks
- + class="hover:bg-primary/20" + >
@@ -255,7 +265,7 @@ :global(.sub-title) { @apply m-2; - @apply text-surface-content/50 text-sm; + @apply text-sm text-muted-foreground; } } diff --git a/frontend/viewer/src/home/Server.svelte b/frontend/viewer/src/home/Server.svelte index 809cf04e91..6927c13542 100644 --- a/frontend/viewer/src/home/Server.svelte +++ b/frontend/viewer/src/home/Server.svelte @@ -2,13 +2,15 @@ import type {IServerStatus} from '$lib/dotnet-types'; import type {Project} from '$lib/services/projects-service'; import {createEventDispatcher} from 'svelte'; - import {mdiBookArrowDownOutline, mdiBookSyncOutline, mdiCloud, mdiRefresh} from '@mdi/js'; + import {mdiCloud} from '@mdi/js'; import LoginButton from '$lib/auth/LoginButton.svelte'; - import {Button, ListItem, Settings} from 'svelte-ux'; + import {ListItem} from 'svelte-ux'; import ButtonListItem from '$lib/utils/ButtonListItem.svelte'; import {useProjectsService} from '$lib/services/service-provider'; import {t} from 'svelte-i18n-lingui'; import ProjectTitle from './ProjectTitle.svelte'; + import {cn} from '$lib/utils'; + import {Button} from '$lib/components/ui/button'; const projectsService = useProjectsService(); @@ -50,34 +52,34 @@ {#if server} {$t`${server.displayName} Server`} {:else} -
+
{/if}
{#if status?.loggedIn} -
-
+
{#if !status || loading} - - -
-
-
-
-
-
-
+ +
+
+
+
+
+
{:else if !projects.length} -

+

{#if status.loggedIn} {$t`No projects`} {:else} @@ -85,35 +87,39 @@ {/if}

{:else} - {#each projects as project} - {@const localProject = matchesProject(localProjects, project)} - {#if localProject?.crdt} - - -
- -
-
-
- {:else} - {@const loading = downloading === project.code} - downloadCrdtProject(project)} disabled={!!downloading}> - - -
- -
-
-
- {/if} - {/each} +
+ {#each projects as project} + {@const localProject = matchesProject(localProjects, project)} + {#if localProject?.crdt} + + + +
+ +
+
+
+ {:else} + {@const loading = downloading === project.code} + downloadCrdtProject(project)} disabled={!!downloading}> + + +
+ +
+
+
+ {/if} + {/each} +
{/if}
diff --git a/frontend/viewer/src/lib/DialogsProvider.svelte b/frontend/viewer/src/lib/DialogsProvider.svelte new file mode 100644 index 0000000000..4cb551df5a --- /dev/null +++ b/frontend/viewer/src/lib/DialogsProvider.svelte @@ -0,0 +1,7 @@ + + + + diff --git a/frontend/viewer/src/lib/DictionaryEntry.svelte b/frontend/viewer/src/lib/DictionaryEntry.svelte index 3f32b489cc..a379fc179b 100644 --- a/frontend/viewer/src/lib/DictionaryEntry.svelte +++ b/frontend/viewer/src/lib/DictionaryEntry.svelte @@ -1,24 +1,42 @@ -
- +{#snippet senseNumber(index: number)} + {#if showLinks} + + {index + 1} + + {:else} + {index + 1} + {/if} + {' · '} +{/snippet} + +
+
+ {@render actions?.()} +
+ {#each headwords as headword, i (headword.wsId)} - {#if i > 0}/{/if} + {#if i > 0}/{/if} {headword.value} {/each} {#each senses as sense, i (sense.id)} {#if senses.length > 1}
- {i + 1} · + {@render senseNumber(i)} {/if} {#if sense.partOfSpeech} {sense.partOfSpeech} diff --git a/frontend/viewer/src/lib/Editor.svelte b/frontend/viewer/src/lib/Editor.svelte index f267e9e53c..1aa0e1ef1b 100644 --- a/frontend/viewer/src/lib/Editor.svelte +++ b/frontend/viewer/src/lib/Editor.svelte @@ -1,13 +1,13 @@
{#key entry.id} onChange(e.detail)} - on:delete={e => onDelete(e.detail)} + onchange={onChange} + ondelete={onDelete} entry={entry} {readonly}/> {/key} diff --git a/frontend/viewer/src/lib/OpenInFieldWorksButton.svelte b/frontend/viewer/src/lib/OpenInFieldWorksButton.svelte index 362a86e596..a8c9081363 100644 --- a/frontend/viewer/src/lib/OpenInFieldWorksButton.svelte +++ b/frontend/viewer/src/lib/OpenInFieldWorksButton.svelte @@ -1,39 +1,57 @@  -{#if show} + - -{/if} + diff --git a/frontend/viewer/src/lib/RightToolbar.svelte b/frontend/viewer/src/lib/RightToolbar.svelte index 5299e80899..40b50f5364 100644 --- a/frontend/viewer/src/lib/RightToolbar.svelte +++ b/frontend/viewer/src/lib/RightToolbar.svelte @@ -1,12 +1,10 @@  @@ -44,9 +40,6 @@ class:!gap-2={$state.rightToolbarCollapsed} >
-
- -
@@ -60,7 +53,7 @@ {/if}
{$currentView.label} diff --git a/frontend/viewer/src/lib/ThemePicker.svelte b/frontend/viewer/src/lib/ThemePicker.svelte index c403d755a9..bf093deb3b 100644 --- a/frontend/viewer/src/lib/ThemePicker.svelte +++ b/frontend/viewer/src/lib/ThemePicker.svelte @@ -1,5 +1,5 @@ - - diff --git a/frontend/viewer/src/lib/about/AboutDialog.svelte b/frontend/viewer/src/lib/about/AboutDialog.svelte index 282299c1f7..5fc0d2443f 100644 --- a/frontend/viewer/src/lib/about/AboutDialog.svelte +++ b/frontend/viewer/src/lib/about/AboutDialog.svelte @@ -1,7 +1,9 @@ - -
- -
-
-
- -
-
+ + + + {$t`About`} + +
+ +
+ + + +
+
diff --git a/frontend/viewer/src/lib/activity/ActivityView.svelte b/frontend/viewer/src/lib/activity/ActivityView.svelte index 1be5407a5a..9ecbff3c16 100644 --- a/frontend/viewer/src/lib/activity/ActivityView.svelte +++ b/frontend/viewer/src/lib/activity/ActivityView.svelte @@ -1,16 +1,15 @@  - - -
Activity
- {#if !loading} -
+ + + + {$t`Activity`} + + {#if !loading} +
{#if !activity || activity.length === 0} -
No activity found
+
{$t`No activity found`}
{:else} {#each visibleItems as row (row.timestamp)} @@ -88,16 +90,16 @@ title={row.changeName} noShadow on:click={() => selectedRow = row} - class={cls(selectedRow?.commitId === row.commitId ? 'bg-surface-200 selected-entry' : '')}> -
+ class={cls(selectedRow?.commitId === row.commitId ? 'bg-primary/20 dark:bg-primary/20 selected-entry' : 'dark:bg-muted/50 bg-muted/80 hover:bg-muted/30 hover:dark:bg-muted')}> +
{#if row.previousTimestamp} - before + {$t`before`} {:else} - ago + {$t`ago`} {/if}
@@ -110,19 +112,19 @@
{#if selectedRow}
- Author: + {$t`Author:`} {#if selectedRow.metadata.authorName} {selectedRow.metadata.authorName} {:else} - Unknown + {$t`Unknown`} {/if} {#if selectedRow.changes.length > 1} - – ({selectedRow.changes.length} changes) + {$t`– (${selectedRow.changes.length} changes)`} {/if} {#if selectedRow.metadata.extraMetadata['SyncDate']} - Synced ago + {$t`Synced`} {$t`ago`} {/if}
@@ -139,8 +141,8 @@
{/if} -
-
+ + -
- + +{#if label} +
+ + +
+{:else} -
+{/if} diff --git a/frontend/viewer/src/lib/components/reorderer/index.ts b/frontend/viewer/src/lib/components/reorderer/index.ts new file mode 100644 index 0000000000..4d118ee0cb --- /dev/null +++ b/frontend/viewer/src/lib/components/reorderer/index.ts @@ -0,0 +1,13 @@ +import ItemList from './reorderer-item-list.svelte'; +import Root from './reorderer.svelte'; +import Trigger from './reorderer-trigger.svelte'; + +export { + Root, + ItemList, + Trigger, + // + Root as Reorderer, + ItemList as ReordererItemList, + Trigger as ReordererTrigger, +}; diff --git a/frontend/viewer/src/lib/components/reorderer/reorderer-item-list.svelte b/frontend/viewer/src/lib/components/reorderer/reorderer-item-list.svelte new file mode 100644 index 0000000000..ea90d581fc --- /dev/null +++ b/frontend/viewer/src/lib/components/reorderer/reorderer-item-list.svelte @@ -0,0 +1,59 @@ + + + + + + {#each displayItems as item, i} + {@const reorderName = getDisplayName(item) || '–'} + displayIndex = i} + onfocus={() => displayIndex = i} + onSelect={() => { + if (i !== currIndex) move(); + }}> + {i + 1}: + {reorderName} + {#if i === displayIndex || i === currIndex} + + {/if} + + {/each} + diff --git a/frontend/viewer/src/lib/components/reorderer/reorderer-trigger.svelte b/frontend/viewer/src/lib/components/reorderer/reorderer-trigger.svelte new file mode 100644 index 0000000000..dde6fd2f70 --- /dev/null +++ b/frontend/viewer/src/lib/components/reorderer/reorderer-trigger.svelte @@ -0,0 +1,45 @@ + + + + +{#if child} + {@render child({ arrowIcon, props })} +{:else} + + + +{/if} diff --git a/frontend/viewer/src/lib/components/reorderer/reorderer.svelte b/frontend/viewer/src/lib/components/reorderer/reorderer.svelte new file mode 100644 index 0000000000..a82280e560 --- /dev/null +++ b/frontend/viewer/src/lib/components/reorderer/reorderer.svelte @@ -0,0 +1,88 @@ + + + + +{#if count > 1} + {#if children} + {@render children({first, last})} + {:else} + + + + + + + {/if} +{/if} diff --git a/frontend/viewer/src/lib/components/responsive-menu/index.ts b/frontend/viewer/src/lib/components/responsive-menu/index.ts new file mode 100644 index 0000000000..ae13b2d81e --- /dev/null +++ b/frontend/viewer/src/lib/components/responsive-menu/index.ts @@ -0,0 +1,16 @@ +import Content from './responsive-menu-content.svelte'; +import Item from './responsive-menu-item.svelte'; +import Root from './responsive-menu.svelte'; +import Trigger from './responsive-menu-trigger.svelte'; + +export { + Root, + Item, + Trigger, + Content, + // + Root as ResponsiveMenu, + Item as ResponsiveMenuItem, + Trigger as ResponsiveMenuTrigger, + Content as ResponsiveMenuContent, +}; diff --git a/frontend/viewer/src/lib/components/responsive-menu/responsive-menu-content.svelte b/frontend/viewer/src/lib/components/responsive-menu/responsive-menu-content.svelte new file mode 100644 index 0000000000..42be681d56 --- /dev/null +++ b/frontend/viewer/src/lib/components/responsive-menu/responsive-menu-content.svelte @@ -0,0 +1,47 @@ + + +{#if state.contextMenu} + + {@render children?.()} + +{:else if !IsMobile.value} + + {@render children?.()} + +{:else} + +
+ + + + + +
+ {@render children?.()} +
+
+
+{/if} diff --git a/frontend/viewer/src/lib/components/responsive-menu/responsive-menu-item.svelte b/frontend/viewer/src/lib/components/responsive-menu/responsive-menu-item.svelte new file mode 100644 index 0000000000..8eae0e2403 --- /dev/null +++ b/frontend/viewer/src/lib/components/responsive-menu/responsive-menu-item.svelte @@ -0,0 +1,72 @@ + + +{#snippet content()} + {#if icon} + + {/if} + {@render children?.()} +{/snippet} + +{#snippet anchorChild({ props }: { props: Record })} + + {@render content()} + +{/snippet} + +{#if state.contextMenu} + + {@render content()} + +{:else if !IsMobile.value} + + {@render content()} + +{:else if rest.child} + {@render rest.child({ props: mergedProps})} +{:else} + +{/if} diff --git a/frontend/viewer/src/lib/components/responsive-menu/responsive-menu-trigger.svelte b/frontend/viewer/src/lib/components/responsive-menu/responsive-menu-trigger.svelte new file mode 100644 index 0000000000..8ecf272ccb --- /dev/null +++ b/frontend/viewer/src/lib/components/responsive-menu/responsive-menu-trigger.svelte @@ -0,0 +1,49 @@ + + +{#snippet standardTriggerContent()} + +{/snippet} + +{#snippet triggerContent()} + {@render (children ?? standardTriggerContent)()} +{/snippet} + +{#if state.contextMenu} + + {@render triggerContent()} + +{:else if !IsMobile.value} + + {@render triggerContent()} + +{:else} + + {@render triggerContent()} + +{/if} diff --git a/frontend/viewer/src/lib/components/responsive-menu/responsive-menu.svelte b/frontend/viewer/src/lib/components/responsive-menu/responsive-menu.svelte new file mode 100644 index 0000000000..c4428b0f17 --- /dev/null +++ b/frontend/viewer/src/lib/components/responsive-menu/responsive-menu.svelte @@ -0,0 +1,69 @@ + + + + +{#if contextMenu} + + {@render children?.()} + +{:else if !IsMobile.value} + + {@render children?.()} + +{:else} + + {@render children?.()} + +{/if} diff --git a/frontend/viewer/src/lib/components/responsive-popup/responsive-popup.svelte b/frontend/viewer/src/lib/components/responsive-popup/responsive-popup.svelte index 11951c1c1d..0b45ebc0b9 100644 --- a/frontend/viewer/src/lib/components/responsive-popup/responsive-popup.svelte +++ b/frontend/viewer/src/lib/components/responsive-popup/responsive-popup.svelte @@ -3,17 +3,17 @@ import * as Popover from '$lib/components/ui/popover'; import * as Drawer from '$lib/components/ui/drawer'; import { buttonVariants } from '$lib/components/ui/button'; - import { t } from 'svelte-i18n-lingui'; - import type {WithChildren} from 'bits-ui'; - import type {Snippet} from 'svelte'; - let { open = $bindable(false), children, title, trigger }: WithChildren<{ open?: boolean, title: string, trigger: Snippet }> = $props(); + import type {PopoverTriggerProps, WithChildren} from 'bits-ui'; + import {Icon} from '../ui/icon'; + + type TriggerSnippet = PopoverTriggerProps['child']; + + let { open = $bindable(false), children, title, trigger }: WithChildren<{ open?: boolean, title: string, trigger: TriggerSnippet }> = $props(); {#if !IsMobile.value} - - {@render trigger()} - +

{title}

@@ -25,10 +25,11 @@ {:else} - - {@render trigger()} - + + + +
{title} @@ -36,9 +37,6 @@ {#if children} {@render children()} {/if} - - {$t`Close`} -
diff --git a/frontend/viewer/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte b/frontend/viewer/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte index af4dee6e4c..44dfedfb49 100644 --- a/frontend/viewer/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte +++ b/frontend/viewer/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte @@ -18,7 +18,8 @@ {@render children?.()} diff --git a/frontend/viewer/src/lib/components/ui/button/button.svelte b/frontend/viewer/src/lib/components/ui/button/button.svelte index dd13c8f340..c32d7cf813 100644 --- a/frontend/viewer/src/lib/components/ui/button/button.svelte +++ b/frontend/viewer/src/lib/components/ui/button/button.svelte @@ -21,6 +21,7 @@ xs: 'h-8 rounded-md px-2', sm: 'h-9 rounded-md px-3', lg: 'h-11 rounded-md px-8', + 'badge-icon': 'h-5 w-5 min-h-5 min-w-5', 'xs-icon': 'h-8 w-8 min-h-8 min-w-8', icon: 'h-10 w-10 min-h-10 min-w-10', 'extended-fab': 'h-14 pl-4 pr-5', @@ -49,6 +50,7 @@ {#snippet content()} - {#if loading} - - {:else if icon} - + {#if loading || icon} + + {#if loading} + + {:else if icon} + + {/if} + {/if} + {@render children?.()} {/snippet} diff --git a/frontend/viewer/src/lib/components/ui/button/index.ts b/frontend/viewer/src/lib/components/ui/button/index.ts index fd6f0d8c05..28523d63c0 100644 --- a/frontend/viewer/src/lib/components/ui/button/index.ts +++ b/frontend/viewer/src/lib/components/ui/button/index.ts @@ -5,9 +5,12 @@ import Root, { buttonVariants, } from './button.svelte'; +import XButton from './x-button.svelte'; + export { // Root as Button, + XButton, buttonVariants, Root, type ButtonProps, type ButtonSize, type ButtonVariant, type ButtonProps as Props diff --git a/frontend/viewer/src/lib/components/ui/button/x-button.svelte b/frontend/viewer/src/lib/components/ui/button/x-button.svelte new file mode 100644 index 0000000000..993437505a --- /dev/null +++ b/frontend/viewer/src/lib/components/ui/button/x-button.svelte @@ -0,0 +1,16 @@ + + + - -
- + +{#if open} + + + + {$t`Delete ${subject}`} + + + {$t`Are you sure you want to delete ${subjectWithDescription}?`} + + + + + + + +{/if} diff --git a/frontend/viewer/src/lib/entry-editor/EntityListItemActions.svelte b/frontend/viewer/src/lib/entry-editor/EntityListItemActions.svelte index 2a99b17dfc..ae9243ea49 100644 --- a/frontend/viewer/src/lib/entry-editor/EntityListItemActions.svelte +++ b/frontend/viewer/src/lib/entry-editor/EntityListItemActions.svelte @@ -1,90 +1,51 @@ - -{#if !readonly || $features.history} - - {#if !only && !readonly} - - - newIndex = i}> - {#each displayItems as item, j} - - - {/each} - - - {#if first} - - {:else if last} - - {:else} - - {/if} - +{#if !readonly || features.history} +
+ {#if !readonly} + onmove?.(newIndex)} + /> {/if} - {#if $features.history && id} - + {#if features.history && id} + +
{/if} diff --git a/frontend/viewer/src/lib/entry-editor/EntryOrSenseItemList.svelte b/frontend/viewer/src/lib/entry-editor/EntryOrSenseItemList.svelte index fb7bd2a8e0..662512487e 100644 --- a/frontend/viewer/src/lib/entry-editor/EntryOrSenseItemList.svelte +++ b/frontend/viewer/src/lib/entry-editor/EntryOrSenseItemList.svelte @@ -1,29 +1,29 @@ - - `?entryId=${getEntryId(entry)}&search=${getHeadword(entry)?.replace(/\d?$/, '')}`}> - - - - Go to {getHeadword(entry) || '–'} - + + {#snippet menuItems(entry)} + + + + {$t`Go to ${getHeadword(entry) || '–'}`} - - - + + {/snippet} diff --git a/frontend/viewer/src/lib/entry-editor/EntryOrSensePicker.postcss b/frontend/viewer/src/lib/entry-editor/EntryOrSensePicker.postcss index 29e5e63d24..8118d33eef 100644 --- a/frontend/viewer/src/lib/entry-editor/EntryOrSensePicker.postcss +++ b/frontend/viewer/src/lib/entry-editor/EntryOrSensePicker.postcss @@ -2,12 +2,11 @@ .entry-sense-picker { .Collapse { - @apply border-2 !border-t-2 border-surface-100 rounded overflow-hidden elevation-0 m-0 transition-none; + @apply border-2 !border-t-2 rounded overflow-hidden m-0 transition-none; } .entry:not(.disabled.disable-expand) .Collapse { button:hover { - @apply bg-surface-300; & * { @apply bg-transparent; } @@ -16,7 +15,6 @@ /* We need this, because there's a bug that adds aria-expanded="true" even when the expansion panel is disabled */ .entry:not(.disable-expand) .Collapse[aria-expanded="true"] { - @apply border-surface-300; } .CollapseContent .ListItem { @@ -24,12 +22,11 @@ } .entry:is(.selected, :has(.selected)) .Collapse { - @apply !border-accent-500; + } .selected.sense, .selected.entry button:not(.sense) { - @apply !bg-accent-100; - @apply dark:!bg-accent-900; + & > * { @apply bg-transparent; @@ -38,7 +35,7 @@ .disabled { &.entry [slot="trigger"] { - @apply brightness-50 bg-surface-100 pointer-events-none; + @apply brightness-50 pointer-events-none; } &.sense { diff --git a/frontend/viewer/src/lib/entry-editor/EntryOrSensePicker.svelte b/frontend/viewer/src/lib/entry-editor/EntryOrSensePicker.svelte index 5d3f2e5cfc..0384a71ca3 100644 --- a/frontend/viewer/src/lib/entry-editor/EntryOrSensePicker.svelte +++ b/frontend/viewer/src/lib/entry-editor/EntryOrSensePicker.svelte @@ -1,4 +1,4 @@ - - -
-

- {title} -

- -
- {#if $loading} - - {/if} -
-
-
-
- {#each [...$displayedEntries, ...addedEntries] as entry (entry.id)} - {@const disabledEntry = disableEntry?.(entry)} - {@const disableExpand = onlyEntries || (disabledEntry && disabledEntry.disableSenses)} -
- onExpansionChange(event.detail.open, entry, !!disabledEntry)} - > - - {#each entry.senses as sense} - {@const disabledSense = disableSense?.(sense, entry)} - -
+ {/if} + {/each} + {#if searchResource.current.length > displayedEntries.length} + {@const remainingEntries = searchResource.current.length - displayedEntries.length} + + {/if} + {#if displayedEntries.length} + + {/if} +
+ + + {#if !onlyEntries && selectedEntry} +
+

Senses:

+ select(selectedEntry, undefined)}> +
+

{$t`Entry Only`}

+
+
+ {#each selectedEntry.senses as sense} + {@const disabledSense = disableSense?.(sense, selectedEntry)} + select(selectedEntry, sense)}> +
+

{writingSystemService.firstGloss(sense).padStart(1, '–')}

+

{writingSystemService.firstDef(sense).padStart(1, '–')}

+
{#if disabledSense} - + {disabledSense} {/if} - +
{/each} - onClickAddSense(entry)} - /> - -
- {/each} - {#if $displayedEntries.length === 0 && addedEntries.length === 0} -
- {#if $result.search} - No entries found - - {:else if $loading} - - {:else} - Search for an entry {onlyEntries ? '' : 'or sense'} or - - {/if} -
- {/if} - {#if $displayedEntries.length} - - {/if} - {#if $result.entries.length > $displayedEntries.length} -
- {$result.entries.length - $displayedEntries.length} - {#if $result.entries.length === fetchCount}+{/if} -
- more matching entries... + +
+ {/if} +
+ +
- {/if} -
-
-
- - -
-
+ + + + diff --git a/frontend/viewer/src/lib/entry-editor/FieldHelpIcon.svelte b/frontend/viewer/src/lib/entry-editor/FieldHelpIcon.svelte index aadacfe685..842472a318 100644 --- a/frontend/viewer/src/lib/entry-editor/FieldHelpIcon.svelte +++ b/frontend/viewer/src/lib/entry-editor/FieldHelpIcon.svelte @@ -11,7 +11,7 @@ {#if href} - + diff --git a/frontend/viewer/src/lib/entry-editor/ItemList.svelte b/frontend/viewer/src/lib/entry-editor/ItemList.svelte index a94f778d48..3081133714 100644 --- a/frontend/viewer/src/lib/entry-editor/ItemList.svelte +++ b/frontend/viewer/src/lib/entry-editor/ItemList.svelte @@ -1,141 +1,36 @@ - - function remove(item: T): void { - value = value.filter((v) => v !== item); - dispatch('change', { value }); - } +
- {#each value as item, i} - {@const gotoLink = getGotoLink?.(item)} - {@const displayName = getDisplayName(item) || '–'} -
- {#if gotoLink} - - {displayName} - - {:else} - {displayName} - {/if} - newIndex = currIndex = i}> - - - {/each} - - - {#if first || last} - - {:else} - - {/if} - - - {/if} - { - remove(item); - closeMenu(); - }}> - Remove - - - {/if} - - - -
+ {#each items as item, index} + {/each} {#if !readonly}
- + {@render actions?.()}
{/if}
diff --git a/frontend/viewer/src/lib/entry-editor/ItemListItem.svelte b/frontend/viewer/src/lib/entry-editor/ItemListItem.svelte new file mode 100644 index 0000000000..5af905bd39 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/ItemListItem.svelte @@ -0,0 +1,79 @@ + + + + + + + {displayName} + + + + + + {@render menuItems?.(item)} + + {#if !readonly} + + {#if orderable} + + + + {#snippet child({arrowIcon, props})} + + + {$t`Move`} + + {/snippet} + + + + + + + {/if} + remove(item)}> + + {$t`Remove`} + + + {/if} + + + diff --git a/frontend/viewer/src/lib/entry-editor/NewEntryButton.svelte b/frontend/viewer/src/lib/entry-editor/NewEntryButton.svelte index 4524e786b8..c505455e53 100644 --- a/frontend/viewer/src/lib/entry-editor/NewEntryButton.svelte +++ b/frontend/viewer/src/lib/entry-editor/NewEntryButton.svelte @@ -1,15 +1,13 @@ - - diff --git a/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte b/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte index 899313fc7b..28c75b395f 100644 --- a/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte +++ b/frontend/viewer/src/lib/entry-editor/NewEntryDialog.svelte @@ -1,29 +1,41 @@ - -
New {fieldName({id: 'entry'}, $currentView.i18nKey)}
-
+{#if open} + + + + {$t`New ${entryLabel}`} + -
-
-
- {#each errors as error} -

{error}

- {/each} -
-
- - -
-
+ {#if errors.length} +
+ {#each errors as error} +

{error}

+ {/each} +
+ {/if} + + + + + + +{/if} diff --git a/frontend/viewer/src/lib/entry-editor/dialog-service.ts b/frontend/viewer/src/lib/entry-editor/dialog-service.ts deleted file mode 100644 index c4184f5af6..0000000000 --- a/frontend/viewer/src/lib/entry-editor/dialog-service.ts +++ /dev/null @@ -1,30 +0,0 @@ -import {getContext, setContext} from 'svelte'; - -import type DeleteDialog from '$lib/entry-editor/DeleteDialog.svelte'; - -export function initDialogService(rootDialog: () => DeleteDialog | undefined): DialogService { - const dialogService = new DialogService(rootDialog); - setContext('DialogService', dialogService); - return dialogService; -} - -export function useDialogService(): DialogService { - const rootDialog = getContext('DialogService'); - if (!rootDialog) { - throw new Error('DialogService not found'); - } - return rootDialog as DialogService; -} - -export class DialogService { - - constructor(private deleteDialog: () => DeleteDialog | undefined) { - } - - promptDelete(subject: string): Promise { - const deleteDialog = this.deleteDialog(); - if (!deleteDialog) throw new Error('no deleted dialog found'); - // eslint-disable-next-line -- false positive (.ts -> .svelte) - return deleteDialog.prompt(subject); - } -} diff --git a/frontend/viewer/src/lib/entry-editor/entry-persistence.svelte.ts b/frontend/viewer/src/lib/entry-editor/entry-persistence.svelte.ts new file mode 100644 index 0000000000..d4b91da661 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/entry-persistence.svelte.ts @@ -0,0 +1,50 @@ +import {type Props} from './object-editors/EntryEditor.svelte'; +import {useLexboxApi} from '$lib/services/service-provider'; +import {useSaveHandler} from '$lib/services/save-event-service.svelte'; +import {watch, type Getter} from 'runed'; +import type {IEntry, IExampleSentence, ISense} from '$lib/dotnet-types'; + +export class EntryPersistence { + lexboxApi = useLexboxApi(); + saveHandler = useSaveHandler(); + initialEntry: IEntry | undefined = undefined; + constructor(private entryGetter: Getter, private onUpdated: () => void = () => { }) { + watch(entryGetter, (entry) => { + if (entry?.id !== this.initialEntry?.id) this.updateInitialEntry(); + }); + } + + get entryEditorProps(): Partial { + return { + onchange: async (changed: { entry: IEntry }) => { + await this.updateEntry(changed.entry); + this.onUpdated(); + this.updateInitialEntry(); + }, + ondelete: async (e: { entry: IEntry, example?: IExampleSentence, sense?: ISense }) => { + if (e.example !== undefined && e.sense !== undefined) { + await this.saveHandler.handleSave(() => this.lexboxApi.deleteExampleSentence(e.entry.id, e.sense!.id, e.example!.id)); + } else if (e.sense !== undefined) { + await this.saveHandler.handleSave(() => this.lexboxApi.deleteSense(e.entry.id, e.sense!.id)); + } else { + await this.saveHandler.handleSave(() => this.lexboxApi.deleteEntry(e.entry.id)); + this.onUpdated(); + return; + } + this.updateInitialEntry(); + } + }; + } + + private async updateEntry(updatedEntry: IEntry) { + if (this.initialEntry === undefined) throw new Error('Not sure what to compare against'); + if (this.initialEntry.id != updatedEntry.id) throw new Error('Entry id mismatch'); + await this.saveHandler.handleSave(() => this.lexboxApi.updateEntry(this.initialEntry!, updatedEntry)); + } + + private updateInitialEntry() { + const entry = this.entryGetter(); + if (!entry) this.initialEntry = undefined; + else this.initialEntry = JSON.parse(JSON.stringify(entry)) as IEntry; + } +} diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/ComplexFormComponents.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/ComplexFormComponents.svelte index 4a1b5e31ea..9a3790d92f 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/ComplexFormComponents.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/ComplexFormComponents.svelte @@ -1,33 +1,28 @@ -
- -
- dispatch('change', { value })} getEntryId={(e) => e.componentEntryId} getHeadword={(e) => e.componentHeadword}> - - - addComponent(e.detail)} - {disableEntry} {disableSense} /> - - -
-
+ {/snippet} + + {/snippet} + diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/ComplexFormTypes.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/ComplexFormTypes.svelte deleted file mode 100644 index 48b03d5a0d..0000000000 --- a/frontend/viewer/src/lib/entry-editor/field-editors/ComplexFormTypes.svelte +++ /dev/null @@ -1,23 +0,0 @@ - - - writingSystemService.pickBestAlternative(cft.name, 'analysis')} - {readonly} - {id} - wsType="first-analysis" /> diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/ComplexForms.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/ComplexForms.svelte index e0a64d54a1..902ffeb5d2 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/ComplexForms.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/ComplexForms.svelte @@ -1,32 +1,27 @@ -
- -
- dispatch('change', { value })} getEntryId={(e) => e.complexFormEntryId} getHeadword={(e) => e.complexFormHeadword}> - - - addComplexForm(e.detail)} - {disableEntry} /> - - -
-
+ {/snippet} + + {/snippet} + diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/FieldEditor.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/FieldEditor.svelte index e09ac546bb..7ad3e5698b 100644 --- a/frontend/viewer/src/lib/entry-editor/field-editors/FieldEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/field-editors/FieldEditor.svelte @@ -1,24 +1,27 @@ {#if isMultiString(value)} - + {:else if isSingleString(value)} - + {:else if isSingleOption(state)} - + {:else if isMultiOption(state)} - + {/if} diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/MultiFieldEditor.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/MultiFieldEditor.svelte deleted file mode 100644 index 274a6e792a..0000000000 --- a/frontend/viewer/src/lib/entry-editor/field-editors/MultiFieldEditor.svelte +++ /dev/null @@ -1,72 +0,0 @@ - - -
- -
- {#each writingSystems as ws, idx (ws.wsId)} - dispatch('change', { value })} - bind:value={value[ws.wsId]} - bind:unsavedChanges={unsavedChanges[ws.wsId]} - autofocus={autofocus && (idx == 0)} - label={collapse ? undefined : ws.abbreviation} - labelPlacement={collapse ? undefined : 'left'} - placeholder={collapse ? ws.abbreviation : undefined} - {readonly} - /> - {/each} -
-
- - diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/MultiOptionEditor.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/MultiOptionEditor.svelte deleted file mode 100644 index 2794299acf..0000000000 --- a/frontend/viewer/src/lib/entry-editor/field-editors/MultiOptionEditor.svelte +++ /dev/null @@ -1,115 +0,0 @@ - - -{#key options} - -{/key} -
- -
- -
-
diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/MultiOptionEditor.test.svelte.ts b/frontend/viewer/src/lib/entry-editor/field-editors/MultiOptionEditor.test.svelte.ts deleted file mode 100644 index 66df246a87..0000000000 --- a/frontend/viewer/src/lib/entry-editor/field-editors/MultiOptionEditor.test.svelte.ts +++ /dev/null @@ -1,191 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ -import {readable} from 'svelte/store' -import {beforeAll, beforeEach, describe, expect, expectTypeOf, test} from 'vitest' - -import {render, screen} from '@testing-library/svelte' -import userEvent, {type UserEvent} from '@testing-library/user-event' -import MultiOptionEditor from './MultiOptionEditor.svelte' -import type {ComponentProps} from 'svelte' -import {WritingSystemService} from '$lib/writing-system-service' -import {WritingSystemType} from '$lib/dotnet-types' -import {views} from '$lib/views/view-data' -import {polyfillMockAnimations} from '../../../test-utils' - -type Option = {id: string}; - -const value = ['2', '3', '4']; -const options: Option[] = ['1', '2', '3', '4', '5'].map(id => ({id})); - -const context = new Map([ - ['writingSystems', readable(new WritingSystemService({ - analysis: [], - vernacular: [{ - id: 'test', - wsId: 'test', - name: 'test', - abbreviation: 'test', - font: 'test', - exemplars: ['test'], - order: 0, - type: WritingSystemType.Vernacular, - isAudio: false, - }], - }))], - ['currentView', readable(views[0])], -]); - -let user: UserEvent; - -const reusedProps: Pick>, 'id' | 'wsType' | 'name' | 'readonly'> = { - id: 'semanticDomains', - wsType: 'vernacular', - name: 'test', - readonly: false, -}; - -let props = $state>>()!; - -beforeEach(() => { - user = userEvent.setup(); - props = { - ...reusedProps, - valuesAreIds: true, - value, - options, - getOptionLabel: (option) => option.id, - }; - render(MultiOptionEditor, { - context, - props, - }); -}); - -beforeAll(() => { - polyfillMockAnimations(); -}); - -describe('MultiOptionEditor value sorting', () => { - test('appends new options to the end in the order they\'re selected', async () => { - await user.click(screen.getByRole('textbox')); - await user.click(screen.getByLabelText('1')); - await user.click(screen.getByLabelText('5')); - await user.click(screen.getByRole('button', {name: 'Apply'})); - expect(props.value).toStrictEqual(['2', '3', '4', '1', '5']); - }); - - test('removes deselected options without changing the order', async () => { - await user.click(screen.getByRole('textbox')); - await user.click(screen.getByLabelText('3')); - await user.click(screen.getByRole('button', {name: 'Apply'})); - expect(props.value).toStrictEqual(['2', '4']); - }); - - test('moves double-toggled options to the end', async () => { - await user.click(screen.getByRole('textbox')); - await user.click(screen.getByLabelText('3')); - await user.click(screen.getByLabelText('3')); - await user.click(screen.getByRole('button', {name: 'Apply'})); - expect(props.value).toStrictEqual(['2', '4', '3']); - }); -}); - -describe('MultiOptionEditor displayed sorting', () => { - test('matches option sorting if `preserveOrder` option is NOT set', async () => { - await user.click(screen.getByRole('textbox')); - await user.click(screen.getByLabelText('1')); - await user.click(screen.getByLabelText('5')); - await user.click(screen.getByRole('button', {name: 'Apply'})); - expect(props.value).toStrictEqual(['2', '3', '4', '1', '5']); - expect(screen.getByRole('textbox').value).toBe('1, 2, 3, 4, 5'); - }); - - test('matches values sorting if `preserveOrder` option is set', async () => { - props.preserveOrder = true; - await user.click(screen.getByRole('textbox')); - await user.click(screen.getByLabelText('1')); - await user.click(screen.getByLabelText('5')); - await user.click(screen.getByRole('button', {name: 'Apply'})); - expect(props.value).toStrictEqual(['2', '3', '4', '1', '5']); - expect(screen.getByRole('textbox').value).toBe('2, 3, 4, 1, 5'); - }); -}); - -describe('MultiOptionEditor configurations', () => { - test('supports string or { id: string} values and { id: string } options out of the box', () => { - expectTypeOf({ - ...reusedProps, - value, - options, - getOptionLabel: (option: Option) => option.id, - valuesAreIds: true, - } as const).toMatchTypeOf>>(); - - expectTypeOf({ - ...reusedProps, - value: value.map(id => ({id})), - options, - getOptionLabel: (option: Option) => option.id, - } as const).toMatchTypeOf>>(); - }); - - test('requires valuesAreIds to be set to true for out of the box support for string values, because we need to know the type at runtime', () => { - type Props = ComponentProps>; - expectTypeOf({ - ...reusedProps, - value, - options, - getOptionLabel: (option: Option) => option.id, - valuesAreIds: true, - } as const).toMatchTypeOf(); - - expectTypeOf({ - ...reusedProps, - value, - options, - getOptionLabel: (option: Option) => option.id, - // valuesAreIds: true, - } as const).not.toMatchTypeOf(); - }); - - test('requires getValueId and getValueById for unsupported value types', () => { - type Props = ComponentProps>; - expectTypeOf({ - ...reusedProps, - value: value.map((v) => ({value: v})), - getValueId: (v: {value: string}) => v.value, - getValueById: (id: string) => ({value: id}), - options, - getOptionLabel: (option: Option) => option.id, - } as const).toMatchTypeOf(); - - expectTypeOf({ - ...reusedProps, - value: value.map((v) => ({value: v})), - // getValueId: (v: {value: string}) => v.value, - // getValueById: (id: string) => ({value: id}), - options, - getOptionLabel: (option: Option) => option.id, - } as const).not.toMatchTypeOf(); - }); - - test('requires getOptionId for unsupported option types', () => { - type Props = ComponentProps>; - expectTypeOf({ - ...reusedProps, - value, - valuesAreIds: true, - options: options.map((option: Option) => ({code: option.id})), - getOptionLabel: (option: {code: string}) => option.code, - getOptionId: (option: {code: string}) => option.code, - } as const).toMatchTypeOf(); - - expectTypeOf({ - ...reusedProps, - value, - valuesAreIds: true, - options: options.map((option: Option) => ({code: option.id})), - getOptionLabel: (option: {code: string}) => option.code, - // getOptionId: (option: {code: string}) => option.code, - } as const).not.toMatchTypeOf(); - }); -}); diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/SingleFieldEditor.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/SingleFieldEditor.svelte deleted file mode 100644 index b0be8550f5..0000000000 --- a/frontend/viewer/src/lib/entry-editor/field-editors/SingleFieldEditor.svelte +++ /dev/null @@ -1,30 +0,0 @@ - - -
- -
- -
-
diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/SingleOptionEditor.svelte b/frontend/viewer/src/lib/entry-editor/field-editors/SingleOptionEditor.svelte deleted file mode 100644 index 266cbffd48..0000000000 --- a/frontend/viewer/src/lib/entry-editor/field-editors/SingleOptionEditor.svelte +++ /dev/null @@ -1,106 +0,0 @@ - - -{#key options} - -{/key} -
- -
- -
-
diff --git a/frontend/viewer/src/lib/entry-editor/field-editors/SingleOptionEditor.test.svelte.ts b/frontend/viewer/src/lib/entry-editor/field-editors/SingleOptionEditor.test.svelte.ts deleted file mode 100644 index ad5657c076..0000000000 --- a/frontend/viewer/src/lib/entry-editor/field-editors/SingleOptionEditor.test.svelte.ts +++ /dev/null @@ -1,145 +0,0 @@ -import {readable} from 'svelte/store' -import {beforeAll, beforeEach, describe, expect, expectTypeOf, test} from 'vitest' - -import {render, screen} from '@testing-library/svelte' -import userEvent, {type UserEvent} from '@testing-library/user-event' -import SingleOptionEditor from './SingleOptionEditor.svelte' -import type {ComponentProps} from 'svelte' -import {WritingSystemService} from '$lib/writing-system-service' -import {views} from '$lib/views/view-data' -import {writingSystems} from '$lib/demo-entry-data' -import {polyfillMockAnimations} from '../../../test-utils' - -type Option = { id: string }; - -const value = '2'; -const options: Option[] = ['1', '2', '3', '4', '5'].map(id => ({id})); - -const context = new Map([ - ['writingSystems', readable(new WritingSystemService(writingSystems))], - ['currentView', readable(views[0])], -]); - - -const reusedProps: Pick>, 'id' | 'wsType' | 'name' | 'readonly'> = { - id: 'partOfSpeechId', - wsType: 'vernacular', - name: 'test', - readonly: false, -}; - -beforeAll(() => { - polyfillMockAnimations(); -}); - -describe('SingleOptionEditor', () => { - - let user: UserEvent; - - const props = $state({ - ...reusedProps, - valueIsId: true, - value, - options, - getOptionLabel: (option: {id: string}) => option.id, - } as const); - - beforeEach(() => { - user = userEvent.setup(); - render(SingleOptionEditor, { - context, - props, - }); - }); - - test('can change selection', async () => { - await user.click(screen.getByRole('textbox')); - await user.click(screen.getByRole('option', {name: '5'})); - expect(props.value).toBe('5'); - - await user.click(screen.getByRole('textbox')); - await user.click(screen.getByRole('option', {name: '3'})); - expect(props.value).toBe('3'); - }); -}); - -describe('SingleOptionEditor configurations', () => { - - test('supports string or { id: string} values and { id: string } options out of the box', () => { - expectTypeOf({ - ...reusedProps, - value, - options, - getOptionLabel: (option: Option) => option.id, - valueIsId: true, - } as const).toMatchTypeOf>>(); - - expectTypeOf({ - ...reusedProps, - value: {id: value}, - options, - getOptionLabel: (option: Option) => option.id, - } as const).toMatchTypeOf>>(); - }); - - test('requires valueIsId to be set to true for out of the box support for string values, because we need to know the type at runtime', () => { - type Props = ComponentProps>; - expectTypeOf({ - ...reusedProps, - value, - options, - getOptionLabel: (option: Option) => option.id, - valueIsId: true, - } as const).toMatchTypeOf(); - - expectTypeOf({ - ...reusedProps, - value, - options, - getOptionLabel: (option: Option) => option.id, - // valueIsId: true, - } as const).not.toMatchTypeOf(); - }); - - test('requires getValueId and getValueById for unsupported value types', () => { - type Props = ComponentProps>; - expectTypeOf({ - ...reusedProps, - value: {value}, - getValueId: (v: {value: string} | undefined) => v?.value, - getValueById: (id: string | undefined) => id ? {value: id} : undefined, - options, - getOptionLabel: (option: Option) => option.id, - } as const).toMatchTypeOf(); - - expectTypeOf({ - ...reusedProps, - value: {value}, - // getValueId: (v: {value: string} | undefined) => v?.value, - // getValueById: (id: string | undefined) => id ? {value: id} : undefined, - options, - getOptionLabel: (option: Option) => option.id, - } as const).not.toMatchTypeOf(); - }); - - test('requires getOptionId for unsupported option types', () => { - type Props = ComponentProps>; - expectTypeOf({ - ...reusedProps, - value, - valueIsId: true, - options: options.map((option) => ({code: option.id})), - getOptionLabel: (option: {code: string}) => option.code, - getOptionId: (option: {code: string}) => option.code, - } as const).toMatchTypeOf(); - - expectTypeOf({ - ...reusedProps, - value, - valueIsId: true, - options: options.map((option) => ({code: option.id})), - getOptionLabel: (option: {code: string}) => option.code, - // getOptionId: (option: {code: string}) => option.code, - } as const).not.toMatchTypeOf(); - }); -}); diff --git a/frontend/viewer/src/lib/entry-editor/inputs/CrdtField.svelte b/frontend/viewer/src/lib/entry-editor/inputs/CrdtField.svelte index ec4bddc729..e204ea999c 100644 --- a/frontend/viewer/src/lib/entry-editor/inputs/CrdtField.svelte +++ b/frontend/viewer/src/lib/entry-editor/inputs/CrdtField.svelte @@ -141,6 +141,6 @@ diff --git a/frontend/viewer/src/lib/entry-editor/inputs/CrdtMultiOptionField.svelte b/frontend/viewer/src/lib/entry-editor/inputs/CrdtMultiOptionField.svelte deleted file mode 100644 index a3b9dafa9d..0000000000 --- a/frontend/viewer/src/lib/entry-editor/inputs/CrdtMultiOptionField.svelte +++ /dev/null @@ -1,68 +0,0 @@ - - - dispatch('change', { value: e.detail.value})} bind:value bind:unsavedChanges let:editorValue let:onEditorValueChange viewMergeButtonPortal={append}> - { - const newValue = [...e.detail.value ?? []]; - // on changes, the order of the value reverts to the order of the options (because they're sorted in the UI?), - // so we have to "undo" that - if (preserveOrder) preserveSortOrder(newValue); - onEditorValueChange(newValue ?? [], true) - }} - value={editorValue} - disabled={readonly} - options={sortedOptions} - menuProps={{resize: true}} - icon={mdiMagnify} - formatSelected={({ value, options }) => { - return (preserveOrder - // sorted by order of selection - ? value?.map(v => options.find(o => o.value === v)?.label).filter(label => !!label).join(', ') - // sorted according to the order of options (e.g. alphabetical or by semantic domain) - : options.map(o => o.label).join(', ')) ?? 'None'; - }} - infiniteScroll - clearSearchOnOpen={false} - clearable={false} - class="ws-field" - classes={{ root: `${hasHadValue.pushAndGet(editorValue) ? '' : 'unused'} ${readonly ? 'readonly' : ''}`, field: 'field-container' }} - {label} - {labelPlacement} - {placeholder}> - - - diff --git a/frontend/viewer/src/lib/entry-editor/inputs/CrdtOptionField.svelte b/frontend/viewer/src/lib/entry-editor/inputs/CrdtOptionField.svelte deleted file mode 100644 index d90ccc770d..0000000000 --- a/frontend/viewer/src/lib/entry-editor/inputs/CrdtOptionField.svelte +++ /dev/null @@ -1,39 +0,0 @@ - - - - {#key options} - onEditorValueChange(e.detail.value, true)} - value={editorValue} - disabled={readonly} - options={sortedOptions} - clearSearchOnOpen={false} - clearable={false} - search={() => Promise.resolve()} - class="ws-field" - classes={{ root: `${hasHadValue.pushAndGet(editorValue) ? '' : 'unused'} ${readonly ? 'readonly' : ''}`, field: 'field-container' }} - {label} - {labelPlacement} - {placeholder}> - - - {/key} - diff --git a/frontend/viewer/src/lib/entry-editor/object-editors/AddSenseFab.svelte b/frontend/viewer/src/lib/entry-editor/object-editors/AddSenseFab.svelte index 7c66fcbc19..465bd2ebfa 100644 --- a/frontend/viewer/src/lib/entry-editor/object-editors/AddSenseFab.svelte +++ b/frontend/viewer/src/lib/entry-editor/object-editors/AddSenseFab.svelte @@ -1,42 +1,16 @@ - diff --git a/frontend/viewer/src/lib/entry-editor/object-editors/EntityEditor.svelte b/frontend/viewer/src/lib/entry-editor/object-editors/EntityEditor.svelte index 5689cf841a..15f758f0e4 100644 --- a/frontend/viewer/src/lib/entry-editor/object-editors/EntityEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/object-editors/EntityEditor.svelte @@ -2,10 +2,9 @@ import type { CustomFieldConfig } from '../../config-types'; import FieldEditor from '../field-editors/FieldEditor.svelte'; - export let readonly: boolean; export let customFieldConfigs: CustomFieldConfig[]; {#each customFieldConfigs as fieldConfig} - + {/each} diff --git a/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte b/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte index a03abcd084..1142521913 100644 --- a/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte +++ b/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditor.svelte @@ -1,35 +1,46 @@ + -
-
- dispatch('change', {entry})} - bind:value={entry.lexemeForm} - {readonly} - autofocus={modalMode} - id="lexemeForm" - wsType="vernacular"/> - - dispatch('change', {entry})} - bind:value={entry.citationForm} - {readonly} - id="citationForm" - wsType="vernacular"/> - - {#if !modalMode} - - dispatch('change', {entry})} - bind:value={entry.complexForms} - {readonly} - {entry} - id="complexForms" /> - - dispatch('change', {entry})} - bind:value={entry.complexFormTypes} - {readonly} - id="complexFormTypes" /> - - dispatch('change', {entry})} - bind:value={entry.components} - {readonly} - {entry} - id="components" /> - - {/if} - - dispatch('change', {entry})} - bind:value={entry.literalMeaning} - {readonly} - id="literalMeaning" - wsType="vernacular"/> - dispatch('change', {entry})} - bind:value={entry.note} - {readonly} - id="note" - wsType="analysis"/> - dispatch('change', {entry})} - /> -
- - {#each entry.senses as sense, i (sense.id)} -
-
-
-

{fieldName({id: 'sense'}, $currentView.i18nKey)} {i + 1}

-
-
- writingSystemService.firstDefOrGlossVal(sense))} + + + onchange?.({entry})} /> + + {#each entry.senses as sense, i (sense.id)} + +
+
+

{pt($t`Sense`, $t`Meaning`, $currentView)} {i + 1}

+
+ writingSystemService.firstDefOrGlossVal(sense)} {readonly} - on:move={(e) => moveSense(sense, e.detail)} - on:delete={() => deleteSense(sense)} id={sense.id} /> + onmove={(newIndex) => moveSense(sense, newIndex)} + ondelete={() => deleteSense(sense)} id={sense.id} />
-
- onSenseChange(sense)}/> - - {#if sense.exampleSentences.length} -
- {#each sense.exampleSentences as example, j (example.id)} -
-
-
-

Example {j + 1}

- -
- writingSystemService.firstSentenceOrTranslationVal(example))} - on:move={(e) => moveExample(sense, example, e.detail)} - on:delete={() => deleteExample(sense, example)} - id={example.id} - /> -
- - onExampleChange(sense, example)} - /> -
- {/each} -
- {/if} - {#if !readonly && canAddExample} -
- -
- {/if} -
- {/each} - {#if !readonly && canAddSense} -
-
- - -
-
- -
- {/if} -
- -{#if !modalMode} -{@const willRenderAnyButtons = $features.history || !readonly} - {#if willRenderAnyButtons && !disablePortalButtons} - - - - {/if} - {#if $features.history} - - {/if} - - - {#if projectViewState.userPickedEntry} -
- {#if !readonly} - - Delete {fieldName({id: 'entry'}, $currentView.i18nKey)} - - {/if} - {#if $features.history} - showHistoryView = true} icon={mdiHistory}> - History - - {/if} -
+ {/if} + + {/each} + {#if !readonly && canAddSense} +
+ {#if IsMobile.value && !modalMode} + + + + + {:else} +
+
{/if} - -
- - {/if} - {#if $features.history} - - {/if} -{/if} + {/if} + + - diff --git a/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditorPrimitive.svelte b/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditorPrimitive.svelte new file mode 100644 index 0000000000..d36234c3d9 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/object-editors/EntryEditorPrimitive.svelte @@ -0,0 +1,120 @@ + + + + + + + onFieldChanged('lexemeForm')} + bind:value={entry.lexemeForm} + {readonly} + autofocus={modalMode} + writingSystems={writingSystemService.vernacular} /> + + + + + + + onFieldChanged('citationForm')} + bind:value={entry.citationForm} + {readonly} + writingSystems={writingSystemService.vernacular} /> + + + + {#if !modalMode} + + + + onFieldChanged('complexForms')} + bind:value={entry.complexForms} + {readonly} + {entry} /> + + + + + + + onFieldChanged('complexFormTypes')} + bind:values={entry.complexFormTypes} + sortValuesBy="selectionOrder" + options={complexFormTypes.current} + labelSelector={(cft) => writingSystemService.pickBestAlternative(cft.name, 'analysis')} + {readonly} + idSelector="id" /> + + + + + + + onFieldChanged('components')} + bind:value={entry.components} + {readonly} + {entry} /> + + + {/if} + + + + + onFieldChanged('literalMeaning')} + bind:value={entry.literalMeaning} + {readonly} + writingSystems={writingSystemService.vernacular} /> + + + + + + + onFieldChanged('note')} + bind:value={entry.note} + {readonly} + writingSystems={writingSystemService.analysis} /> + + + diff --git a/frontend/viewer/src/lib/entry-editor/object-editors/ExampleEditor.svelte b/frontend/viewer/src/lib/entry-editor/object-editors/ExampleEditor.svelte deleted file mode 100644 index 03032faf80..0000000000 --- a/frontend/viewer/src/lib/entry-editor/object-editors/ExampleEditor.svelte +++ /dev/null @@ -1,33 +0,0 @@ - -
- - - - -
diff --git a/frontend/viewer/src/lib/entry-editor/object-editors/ExampleEditorPrimitive.svelte b/frontend/viewer/src/lib/entry-editor/object-editors/ExampleEditorPrimitive.svelte new file mode 100644 index 0000000000..62d5ad4a16 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/object-editors/ExampleEditorPrimitive.svelte @@ -0,0 +1,67 @@ + + + + + + + onFieldChanged('sentence')} + bind:value={example.sentence} + {readonly} + writingSystems={writingSystemService.vernacular} /> + + + + + + + onFieldChanged('translation')} + bind:value={example.translation} + {readonly} + writingSystems={writingSystemService.analysis} /> + + + + {#if writingSystemService.defaultAnalysis} + + + + onFieldChanged('reference')} + bind:value={example.reference} + {readonly} + writingSystem={writingSystemService.defaultAnalysis} /> + + + {/if} + diff --git a/frontend/viewer/src/lib/entry-editor/object-editors/SenseEditor.svelte b/frontend/viewer/src/lib/entry-editor/object-editors/SenseEditor.svelte deleted file mode 100644 index e239009acf..0000000000 --- a/frontend/viewer/src/lib/entry-editor/object-editors/SenseEditor.svelte +++ /dev/null @@ -1,52 +0,0 @@ - - -
- - - sense.partOfSpeechId = sense.partOfSpeech?.id} - on:change - bind:value={sense.partOfSpeech} - options={$partsOfSpeech} - getOptionLabel={(pos) => pos.label} - {readonly} - id="partOfSpeechId" - wsType="first-analysis" /> - `${sd.code} ${writingSystemService.pickBestAlternative(sd.name, 'analysis')}`} - {readonly} - id="semanticDomains" - wsType="first-analysis" /> - -
diff --git a/frontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.svelte b/frontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.svelte new file mode 100644 index 0000000000..118a18f197 --- /dev/null +++ b/frontend/viewer/src/lib/entry-editor/object-editors/SenseEditorPrimitive.svelte @@ -0,0 +1,88 @@ + + + + + + + onFieldChanged('gloss')} + bind:value={sense.gloss} + {readonly} + writingSystems={writingSystemService.analysis} /> + + + + + + + onFieldChanged('definition')} + bind:value={sense.definition} + {readonly} + writingSystems={writingSystemService.analysis} /> + + + + + + + item.label} + drawerTitle={`Part of speech`} + filterPlaceholder={`Filter parts of speech...`} + placeholder={`🤷 nothing here`} + emptyResultsPlaceholder={`Looked hard, found nothing`} + options={partsOfSpeech} + > + + + + + + + + +
{JSON.stringify(entry, null, 2)}
+
+ + + diff --git a/frontend/viewer/src/lib/sandbox/OptionSandbox.svelte b/frontend/viewer/src/lib/sandbox/OptionSandbox.svelte deleted file mode 100644 index 733ffdcce8..0000000000 --- a/frontend/viewer/src/lib/sandbox/OptionSandbox.svelte +++ /dev/null @@ -1,79 +0,0 @@ - - value.map(v => ({id: v}))} unmap={(value => value.map(v => v.id))}/> - value.map(v => findOption(v.id))} unmap={(value => value)} /> -
- o.label} wsType="analysis" readonly={false} {options} /> - o.label} wsType="analysis" readonly={false} {options} /> - o.label} wsType="analysis" readonly={false} {options} /> -
-
-

selected: {idValue.join('|')}

- -
diff --git a/frontend/viewer/src/lib/sandbox/Sandbox.svelte b/frontend/viewer/src/lib/sandbox/Sandbox.svelte index 6e1a969954..8b78df1acf 100644 --- a/frontend/viewer/src/lib/sandbox/Sandbox.svelte +++ b/frontend/viewer/src/lib/sandbox/Sandbox.svelte @@ -1,42 +1,34 @@ + let nameSearchParam = new QueryParamState({key: 'name'}); + let isGood = $state(false); + let isBetter = $state(false); + useBackHandler({addToStack: () => isGood, onBack: () => isGood = false}); + useBackHandler({addToStack: () => isBetter, onBack: () => isBetter = false}); + let dialogOpen = $state(false); + useBackHandler({addToStack: () => dialogOpen, onBack: () => dialogOpen = false}); + + const variants = Object.keys(buttonVariants.variants.variant) as unknown as (keyof typeof buttonVariants.variants.variant)[]; + const sizes = Object.keys(buttonVariants.variants.size) as unknown as (keyof typeof buttonVariants.variants.size)[]; + + let buttonsLoading = $state(false); + function testLoading() { + buttonsLoading = true; + setTimeout(() => { + buttonsLoading = false; + }, 1000); + } + +

Shadcn Sandbox @@ -112,71 +129,15 @@

- - -
-
- @lg -
-
- @3xl -
-
-
-
- - - - Selection - Option - Random - - -

- Only comes into effect while editing, because we don't want to make any changes implicitly. -

-
-
-
-
- -
-
-
- -
-
- selectedDomains, - (newValues) => selectedDomains = newValues} - idSelector="label" - labelSelector={(item) => item.label} - sortValuesBy={sortSemanticDomainValuesBy} - drawerTitle={$t`Semantic domains`} - filterPlaceholder={$t`Filter semantic domains...`} - placeholder={$t`🤷 nothing here`} - emptyResultsPlaceholder={$t`Looked hard, found nothing`} - options={allDomains}> - -
-
-
-
-
-
- - - - -
+
- - +
+ + +
+
{JSON.stringify(richString, null, 2).replaceAll(lineSeparator, '\n')}
@@ -195,64 +156,103 @@
+
+

Entry picker example

+ + + + Entry only + Entry or Sense + + + + selectedEntryHistory.push(e)}/> +
+ {#each selectedEntryHistory as selected} +

+ Entry: {writingSystemService.headword(selected.entry)} + {#if selected.sense} + Sense: {writingSystemService.firstGloss(selected.sense)} + {/if} +

+ {/each} +
+
+
+

Reorder example

+ + + Vertical + Horizontal + + + + + Times 1 + Times 100 + + + +
+

Current item: {currentItem}

+ +
+
+ {JSON.stringify(items)} +
+
+
+

Search Params binding

+ + Go to Batman +

These switches should respect the back button but only for being turned off

+ + + + Show Dialog + +
+ Goto Project view +
+
+
+

-

- Svelte-UX Sandbox -

-
- MultiOptionEditor configurations - -
- -
-
- Lower level editor -
- String values and MenuOptions - -
-
-
-

selected: {crdtValue.join('|')}

- crdtValue = ['c']}>Select Charlie only -
-
-
Notifications - testingService?.throwException()}>Throw Exception - testingService?.throwExceptionAsync()}>Throw Exception Async - AppNotification.display('This is a simple notification', 'info')}>Simple + + + + +
Button - +
-
- ButtonListItem - - Increment Async - + click count: {count}
@@ -269,20 +269,39 @@ {/each}
-
- f.id)} respectOrder> - - -
+ + + f.id)} respectOrder> + + + + {#snippet failed(error)} + Error opening override fields {error} + {/snippet} +
+
+
+

Buttons

+
+
+
+ {#each variants as variant} + + {/each} + {#each variants as variant} + + {/each} + {#each sizes as size} + + {/each} + {#each sizes as size} +
+
- - diff --git a/frontend/viewer/src/lib/search-bar/SearchBar.svelte b/frontend/viewer/src/lib/search-bar/SearchBar.svelte index b20502178d..5e5f1a4107 100644 --- a/frontend/viewer/src/lib/search-bar/SearchBar.svelte +++ b/frontend/viewer/src/lib/search-bar/SearchBar.svelte @@ -9,7 +9,7 @@ import {useSearch} from './search'; import {useCurrentView} from '$lib/views/view-service'; import {fieldName} from '$lib/i18n'; - import {useWritingSystemService} from '$lib/writing-system-service'; + import {useWritingSystemService} from '$lib/writing-system-service.svelte'; const {search, showSearchDialog} = useSearch(); const writingSystemService = useWritingSystemService(); diff --git a/frontend/viewer/src/lib/semantic-domains.ts b/frontend/viewer/src/lib/semantic-domains.ts index b43b45b66f..b7e0da9e19 100644 --- a/frontend/viewer/src/lib/semantic-domains.ts +++ b/frontend/viewer/src/lib/semantic-domains.ts @@ -1,20 +1,10 @@ -import {derived, type Readable, type Writable, writable} from 'svelte/store'; -import type {ISemanticDomain} from '$lib/dotnet-types'; -import {useLexboxApi} from './services/service-provider'; +import {useProjectContext} from '$lib/project-context.svelte'; -let semanticDomainsStore: Writable | null = null; -export function useSemanticDomains(): Readable { - if (semanticDomainsStore === null) { - semanticDomainsStore = writable(null, (set) => { - useLexboxApi().getSemanticDomains().then(semanticDomains => { - set(semanticDomains); - }).catch(error => { - console.error('Failed to load semantic domains', error); - throw error; - }); - }); - } - return derived(semanticDomainsStore, (semanticDomains) => { - return semanticDomains ?? []; +const semanticDomainsSymbol = Symbol.for('fw-lite-semantic-domains'); +export function useSemanticDomains() { + const projectContext = useProjectContext(); + return projectContext.getOrAddAsync(semanticDomainsSymbol, [], async (api) => { + const semanticDomains = await api.getSemanticDomains(); + return semanticDomains.sort((a, b) => a.code.localeCompare(b.code)); }); } diff --git a/frontend/viewer/src/lib/services/app-launcher-service.ts b/frontend/viewer/src/lib/services/app-launcher-service.ts new file mode 100644 index 0000000000..26daa16c3a --- /dev/null +++ b/frontend/viewer/src/lib/services/app-launcher-service.ts @@ -0,0 +1,6 @@ +import {DotnetService} from '$lib/dotnet-types'; +import type {IAppLauncher} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IAppLauncher'; + +export function useAppLauncherService(): IAppLauncher | undefined { + return window.lexbox.ServiceProvider.tryGetService(DotnetService.AppLauncher); +} diff --git a/frontend/viewer/src/lib/services/dialogs-service.ts b/frontend/viewer/src/lib/services/dialogs-service.ts new file mode 100644 index 0000000000..5ffcecfd27 --- /dev/null +++ b/frontend/viewer/src/lib/services/dialogs-service.ts @@ -0,0 +1,41 @@ +import {useProjectContext} from '$lib/project-context.svelte'; +import type {IEntry} from '$lib/dotnet-types'; +import {useWritingSystemService, type WritingSystemService} from '$lib/writing-system-service.svelte'; + +const symbol = Symbol.for('fw-lite-dialogs'); + +export function useDialogsService() { + const projectContext = useProjectContext(); + const writingSystemService = useWritingSystemService(); + return projectContext.getOrAdd(symbol, () => new DialogsService(writingSystemService)); +} + +export class DialogsService { + constructor(private writingSystemService: WritingSystemService) { + } + + #invokeDeleteDialog: undefined | ((subject: string, subjectDescription?: string) => Promise); + set invokeDeleteDialog(dialog: ((subject: string, subjectDescription?: string) => Promise)) { + this.#invokeDeleteDialog = dialog; + } + #invokeNewEntryDialog: undefined | ((newEntry: Partial) => Promise); + set invokeNewEntryDialog(dialog: ((newEntry: Partial) => Promise)) { + this.#invokeNewEntryDialog = dialog; + } + + async createNewEntry(headword?: string): Promise { + if (!this.#invokeNewEntryDialog) throw new Error('No new entry dialog'); + const partialEntry: Partial = {}; + if (headword) { + const defaultWs = this.writingSystemService.defaultVernacular?.wsId; + if (defaultWs === undefined) throw new Error('No default vernacular'); + partialEntry.lexemeForm = {[defaultWs]: headword}; + } + const entry = await this.#invokeNewEntryDialog(partialEntry); + return entry; + } + async promptDelete(subject: string, subjectDescription?: string): Promise { + if (!this.#invokeDeleteDialog) throw new Error('No delete dialog'); + return this.#invokeDeleteDialog(subject, subjectDescription); + } +} diff --git a/frontend/viewer/src/lib/services/event-bus.ts b/frontend/viewer/src/lib/services/event-bus.ts index 2fd76fe914..85a8e2441d 100644 --- a/frontend/viewer/src/lib/services/event-bus.ts +++ b/frontend/viewer/src/lib/services/event-bus.ts @@ -6,6 +6,10 @@ import type {IFwEvent} from '$lib/dotnet-types/generated-types/FwLiteShared/Even import {FwEventType} from '$lib/dotnet-types/generated-types/FwLiteShared/Events/FwEventType'; import type {IEntryChangedEvent} from '$lib/dotnet-types/generated-types/FwLiteShared/Events/IEntryChangedEvent'; import type {IProjectEvent} from '$lib/dotnet-types/generated-types/FwLiteShared/Events/IProjectEvent'; +import type {IEntryDeletedEvent} from '$lib/dotnet-types/generated-types/FwLiteShared/Events/IEntryDeletedEvent'; +import {ProjectDataFormat} from '$lib/dotnet-types/generated-types/MiniLcm/Models/ProjectDataFormat'; +import {type ProjectContext, useProjectContext} from '$lib/project-context.svelte'; +import {onDestroy} from 'svelte'; export class EventBus { private _onEvent = new Set<(event: IFwEvent) => void>(); @@ -19,11 +23,11 @@ export class EventBus { while (true) { event = await jsEventListener.nextEventAsync(); if (!event) return; - this.distributor(event); + this.notifyEvent(event); } } - private distributor(event: IFwEvent) { + public notifyEvent(event: IFwEvent) { //using set timeout to queue processing events outside the event loop, this prevents the event loop from being blocked setTimeout(() => { this._onEvent.forEach(callback => callback(event)); @@ -39,15 +43,9 @@ export class EventBus { this._onProjectClosed.forEach(callback => callback(reason)); } - public onEntryUpdated(projectName: string, callback: (entry: IEntry) => void): () => void { - // eslint-disable-next-line func-style - const onEventCallback = (event: IFwEvent) => { - if (isEventForProject(event, projectName) && isEntryChangedEvent(event.event)) { - callback(event.event.entry); - } - }; - this._onEvent.add(onEventCallback); - return () => this._onEvent.delete(onEventCallback); + public onEvent(callback: (event: IFwEvent) => void): () => void { + this._onEvent.add(callback); + return () => this._onEvent.delete(callback); } public notifyEntryUpdated(entry: IEntry) { @@ -55,20 +53,71 @@ export class EventBus { } } +export class ProjectEventBus { + + constructor(private projectContext: ProjectContext, private eventBus: EventBus) { + } + + get projectCode() { + return this.projectContext.projectCode; + } + + public notifyEntryDeleted(entryId: string) { + this.notifyProjectEvent({entryId, type: FwEventType.EntryDeleted, isGlobal: false} satisfies IEntryDeletedEvent); + } + + private notifyProjectEvent(event: T) { + this.eventBus.notifyEvent({ + type: FwEventType.ProjectEvent, + isGlobal: true, + project: {name: this.projectCode, dataFormat: ProjectDataFormat.Harmony}, + event: event + } as IProjectEvent); + } + + public onEntryUpdated(callback: (entry: IEntry) => void) { + this.onProjectEvent(event => { + if (isEntryChangedEvent(event)) { + callback(event.entry); + } + }); + } + public onEntryDeleted(callback: (entryId: string) => void) { + this.onProjectEvent(event => { + if (isEntryDeletedEvent(event)) { + callback(event.entryId); + } + }); + } + + private onProjectEvent(callback: (event: IFwEvent) => void) { + const onProjectEventCallback = (event: IFwEvent) => { + if (isProjectEvent(event) && event.project.name === this.projectCode) { + callback(event.event); + } + } + onDestroy(this.eventBus.onEvent(onProjectEventCallback)); + } +} + let changeEventBus: EventBus | undefined = undefined; export function useEventBus(): EventBus { return changeEventBus ??= new EventBus(); } +export function useProjectEventBus() { + return new ProjectEventBus(useProjectContext(), useEventBus()); +} + function isEntryChangedEvent(event: IFwEvent): event is IEntryChangedEvent { return event.type === FwEventType.EntryChanged; } -function isProjectEvent(event: IFwEvent): event is IProjectEvent { - return event.type === FwEventType.ProjectEvent; +function isEntryDeletedEvent(event: IFwEvent): event is IEntryDeletedEvent { + return event.type === FwEventType.EntryDeleted; } -function isEventForProject(event: IFwEvent, projectName: string): event is IProjectEvent { - return isProjectEvent(event) && event.project.name === projectName; +function isProjectEvent(event: IFwEvent): event is IProjectEvent { + return event.type === FwEventType.ProjectEvent; } diff --git a/frontend/viewer/src/lib/services/feature-service.ts b/frontend/viewer/src/lib/services/feature-service.ts index c8cf61f459..1782610bf5 100644 --- a/frontend/viewer/src/lib/services/feature-service.ts +++ b/frontend/viewer/src/lib/services/feature-service.ts @@ -1,17 +1,26 @@ import type {IMiniLcmFeatures} from '$lib/dotnet-types'; -import {getContext, setContext} from 'svelte'; -import {type Readable, type Writable, writable} from 'svelte/store'; - -const featureContextName = 'features'; +import {useProjectContext} from '$lib/project-context.svelte'; export type LexboxFeatures = IMiniLcmFeatures; -export function initFeatures(defaultFeatures: LexboxFeatures): Writable { - const featureStore = writable(defaultFeatures); - setContext>(featureContextName, featureStore); - return featureStore; -} - -export function useFeatures(): Readable { - return getContext>(featureContextName); +export function useFeatures(): LexboxFeatures { + const context = useProjectContext(); + //need to do this as returning context.features would not be reactive + return { + get history() { + return context.features.history; + }, + get feedback() { + return context.features.feedback; + }, + get openWithFlex() { + return context.features.openWithFlex; + }, + get sync() { + return context.features.sync; + }, + get write() { + return context.features.write; + } + }; } diff --git a/frontend/viewer/src/lib/services/history-service.ts b/frontend/viewer/src/lib/services/history-service.ts index 87311abbaf..db34780f0a 100644 --- a/frontend/viewer/src/lib/services/history-service.ts +++ b/frontend/viewer/src/lib/services/history-service.ts @@ -1,13 +1,13 @@ -import {DotnetService, type IEntry, type IExampleSentence, type ISense} from '$lib/dotnet-types'; -import {getContext} from 'svelte'; +import {type IEntry, type IExampleSentence, type ISense} from '$lib/dotnet-types'; import type { IHistoryServiceJsInvokable } from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IHistoryServiceJsInvokable'; import type {IProjectActivity} from '$lib/dotnet-types/generated-types/LcmCrdt/IProjectActivity'; +import {type ProjectContext, useProjectContext} from '$lib/project-context.svelte'; export function useHistoryService() { - const projectName = getContext('project-name'); - return new HistoryService(projectName); + const projectContext = useProjectContext() + return new HistoryService(projectContext); } type EntityType = { entity: IEntry, entityName: 'Entry' } | { entity: ISense, entityName: 'Sense' } | { @@ -33,13 +33,13 @@ export class HistoryService { return undefined; } } - return window.lexbox.ServiceProvider.tryGetService(DotnetService.HistoryService); + return this.projectContext.historyService; } - constructor(private projectName: string) { + constructor(private projectContext: ProjectContext) { } async load(objectId: string) { - const data = await (this.historyApi?.getHistory(objectId) ?? fetch(`/api/history/${this.projectName}/${objectId}`) + const data = await (this.historyApi?.getHistory(objectId) ?? fetch(`/api/history/${this.projectContext.projectName}/${objectId}`) .then(res => res.json())) as HistoryItem[]; if (!Array.isArray(data)) { console.error('Invalid history data', data); @@ -55,7 +55,7 @@ export class HistoryService { async fetchSnapshot(history: HistoryItem, objectId: string): Promise { const data = (await this.historyApi?.getObject(history.commitId, objectId) - ?? await fetch(`/api/history/${this.projectName}/snapshot/commit/${history.commitId}?entityId=${objectId}`) + ?? await fetch(`/api/history/${this.projectContext.projectName}/snapshot/commit/${history.commitId}?entityId=${objectId}`) .then(res => res.json())) as EntityType['entity']; if (this.isEntry(data)) { return {...history, entity: data, entityName: 'Entry'}; diff --git a/frontend/viewer/src/lib/services/save-event-service.svelte.ts b/frontend/viewer/src/lib/services/save-event-service.svelte.ts new file mode 100644 index 0000000000..a036e469db --- /dev/null +++ b/frontend/viewer/src/lib/services/save-event-service.svelte.ts @@ -0,0 +1,27 @@ +import {useProjectContext} from '$lib/project-context.svelte'; + +export type SaveEvent = { saving: true } | { saved: true } | { status: 'saved-to-disk' | 'failed-to-save' }; + +const symbol = Symbol.for('fw-lite-save-event-service'); +export function useSaveHandler(): SaveHandler { + const projectContext = useProjectContext(); + return projectContext.getOrAdd(symbol, () => new SaveHandler()); +} + +export class SaveHandler { + currentEvent: SaveEvent = $state({ status: 'saved-to-disk'}); + async handleSave(saveAction: () => Promise ): Promise { + this.currentEvent = { saving: true }; + let threw = false; + try { + return await saveAction(); + } catch (e) { + this.currentEvent = { status: 'failed-to-save' }; + threw = true; + throw e; + } finally { + if (!threw) + this.currentEvent = { saved: true }; + } + } +} diff --git a/frontend/viewer/src/lib/services/save-event-service.ts b/frontend/viewer/src/lib/services/save-event-service.ts deleted file mode 100644 index 6d9222ffe5..0000000000 --- a/frontend/viewer/src/lib/services/save-event-service.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { writable, type Readable, type Writable } from 'svelte/store'; - -export type SaveEvent = { saving: true } | { saved: true } | { status: 'saved-to-disk' | 'failed-to-save' }; -export type SaveEventEmmiter = Readable; -type SaveEventDispatcher = Writable; -export type SaveHandler = (saveAction: () => Promise) => Promise; -export const saveEventDispatcher: SaveEventDispatcher = writable({ status: 'saved-to-disk' }); -// eslint-disable-next-line func-style -export const saveHandler: SaveHandler = async (saveAction: () => Promise): Promise => { - saveEventDispatcher.set({ saving: true }); - try { - const result = await saveAction(); - saveEventDispatcher.set({ saved: true }); - return result; - } catch (e) { - saveEventDispatcher.set({ status: 'failed-to-save' }); - throw e; - } -}; diff --git a/frontend/viewer/src/lib/services/service-provider-dotnet.ts b/frontend/viewer/src/lib/services/service-provider-dotnet.ts index c98d62c34b..977f55a559 100644 --- a/frontend/viewer/src/lib/services/service-provider-dotnet.ts +++ b/frontend/viewer/src/lib/services/service-provider-dotnet.ts @@ -47,7 +47,10 @@ export class DotNetServiceProvider { export function wrapInProxy(dotnetObject: DotNet.DotNetObject, serviceName: string): unknown { return new Proxy(dotnetObject, { - get(target: DotNet.DotNetObject, prop: string) { + get(target: DotNet.DotNetObject, prop: unknown) { + if (typeof prop !== 'string') return undefined; + //runed resource calls stringify on values to check equality, so we don't want to pass the toJSON call through to the backend + if (prop === 'toJSON') return undefined; const dotnetMethodName = uppercaseFirstLetter(prop); return async function proxyHandler(...args: unknown[]) { console.debug(`[Dotnet Proxy] Calling ${serviceName} method ${dotnetMethodName}`, args); diff --git a/frontend/viewer/src/lib/services/service-provider-signalr.ts b/frontend/viewer/src/lib/services/service-provider-signalr.ts index b9453dd7f4..6c6cfe156d 100644 --- a/frontend/viewer/src/lib/services/service-provider-signalr.ts +++ b/frontend/viewer/src/lib/services/service-provider-signalr.ts @@ -15,7 +15,8 @@ import type { CloseReason, } from '../generated-signalr-client/TypedSignalR.Client/Lexbox.ClientServer.Hubs'; import {useEventBus} from './event-bus'; -import {DotnetService, type IMiniLcmFeatures, type IEntry, type IMiniLcmJsInvokable} from '$lib/dotnet-types'; +import {type IMiniLcmFeatures, type IEntry, type IMiniLcmJsInvokable} from '$lib/dotnet-types'; +import {initProjectContext} from '$lib/project-context.svelte'; // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents type ErrorContext = {error: Error|unknown, methodName?: string, origin: 'method'|'connection'}; @@ -56,7 +57,7 @@ export function SetupSignalR( return Promise.resolve(); } }); - window.lexbox.ServiceProvider.setService(DotnetService.MiniLcmApi, lexboxApiHubProxy); + initProjectContext({api: lexboxApiHubProxy, projectName: 'todo-change-me', projectCode: 'todo-change-me'}); return {connected, lexboxApi: lexboxApiHubProxy}; } diff --git a/frontend/viewer/src/lib/services/service-provider.ts b/frontend/viewer/src/lib/services/service-provider.ts index a706baf744..a191bec859 100644 --- a/frontend/viewer/src/lib/services/service-provider.ts +++ b/frontend/viewer/src/lib/services/service-provider.ts @@ -7,9 +7,6 @@ import type {IFwLiteConfig} from '$lib/dotnet-types/generated-types/FwLiteShared import type { IProjectServicesProvider } from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IProjectServicesProvider'; -import type { - IHistoryServiceJsInvokable -} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IHistoryServiceJsInvokable'; import type {IAppLauncher} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IAppLauncher'; import type { ITroubleshootingService @@ -18,16 +15,15 @@ import type {ITestingService} from '$lib/dotnet-types/generated-types/FwLiteShar import type {IMultiWindowService} from '$lib/dotnet-types/generated-types/FwLiteShared/Services/IMultiWindowService'; import type {IJsEventListener} from '$lib/dotnet-types/generated-types/FwLiteShared/Events/IJsEventListener'; import type {IFwEvent} from '$lib/dotnet-types/generated-types/FwLiteShared/Events/IFwEvent'; +import {useProjectContext} from '../project-context.svelte'; export type ServiceKey = keyof LexboxServiceRegistry; export type LexboxServiceRegistry = { - [DotnetService.MiniLcmApi]: IMiniLcmJsInvokable, [DotnetService.CombinedProjectsService]: ICombinedProjectsService, [DotnetService.AuthService]: IAuthService, [DotnetService.ImportFwdataService]: IImportFwdataService, [DotnetService.FwLiteConfig]: IFwLiteConfig, [DotnetService.ProjectServicesProvider]: IProjectServicesProvider, - [DotnetService.HistoryService]: IHistoryServiceJsInvokable, [DotnetService.AppLauncher]: IAppLauncher, [DotnetService.TroubleshootingService]: ITroubleshootingService, [DotnetService.TestingService]: ITestingService, @@ -87,11 +83,11 @@ export function setupServiceProvider() { } export function useLexboxApi(): IMiniLcmJsInvokable { - return window.lexbox.ServiceProvider.getService(DotnetService.MiniLcmApi); + return useMiniLcmApi(); } export function useMiniLcmApi(): IMiniLcmJsInvokable { - return window.lexbox.ServiceProvider.getService(DotnetService.MiniLcmApi); + return useProjectContext().api; } export function useProjectsService(): ICombinedProjectsService { @@ -112,10 +108,6 @@ export function useProjectServicesProvider(): IProjectServicesProvider { return window.lexbox.ServiceProvider.getService(DotnetService.ProjectServicesProvider); } -export function useAppLauncher(): IAppLauncher | undefined { - return window.lexbox.ServiceProvider.tryGetService(DotnetService.AppLauncher); -} - export function useTroubleshootingService(): ITroubleshootingService | undefined { return window.lexbox.ServiceProvider.tryGetService(DotnetService.TroubleshootingService); } diff --git a/frontend/viewer/src/lib/status/SaveStatus.svelte b/frontend/viewer/src/lib/status/SaveStatus.svelte index 59c6b664fd..98806163bb 100644 --- a/frontend/viewer/src/lib/status/SaveStatus.svelte +++ b/frontend/viewer/src/lib/status/SaveStatus.svelte @@ -1,11 +1,12 @@ - -
Troubleshoot
-
-

Application version: {config.appVersion}

- {#await service?.getDataDirectory() then value} - - - - + + + + {$t`Troubleshoot`} + +
+

{$t`Application version`}: {config.appVersion}

+
+ + + {#await service?.getDataDirectory() then value} + {value} + {/await} +
+
+ + +
-
-
- -
+
diff --git a/frontend/viewer/src/lib/utils/back-handler.svelte.ts b/frontend/viewer/src/lib/utils/back-handler.svelte.ts new file mode 100644 index 0000000000..4f3a5f8965 --- /dev/null +++ b/frontend/viewer/src/lib/utils/back-handler.svelte.ts @@ -0,0 +1,80 @@ +import type {Getter} from 'runed'; +import {on} from 'svelte/events'; +import {onDestroy} from 'svelte'; + +export interface BackHandlerConfig { + /** + * If true, the back handler will be added to the back stack. + * If false, the back handler will be removed from the back stack. + */ + addToStack: Getter; + onBack: () => void; + /** + * Identifies the back handler, used for warnings and debugging. + */ + key?: string; +} + + +class BackHandler { + #ignoreNextBack: boolean = false; + static #backStack: BackHandler[] = []; + constructor(private config: BackHandlerConfig) { + $effect(() => { + if (this.config.addToStack()) { + if (BackHandler.#backStack.includes(this)) return;//already added + BackHandler.#backStack.push(this); + //add new history to ensure back doesn't pop some other state (like a url change) + history.pushState(null, ''); + } else { + this.remove(); + } + }); + onDestroy(() => this.remove()); + onDestroy(on(window, 'popstate', () => { + if (this.#ignoreNextBack) { + this.#ignoreNextBack = false; + return; + } + if (this.isNextBack) { + //setTimeout ensures all popstate events are processed before we call the onBack callback + setTimeout(() => { + BackHandler.#backStack.pop(); + this.config.onBack(); + }); + } + })); + } + + get isNextBack() { + return BackHandler.#backStack.at(-1) === this; + } + + private remove() { + const count = BackHandler.#backStack.length; + BackHandler.#backStack = BackHandler.#backStack.filter(b => b !== this); + if (count !== BackHandler.#backStack.length) { + //if we removed the last back state, we need to remove the history entry that was pushed + const currentLocation = location.href; + setTimeout(() => { + //navigation triggered since remove was called, we don't want to go back now as that would not undo our history but a navigation event + if (currentLocation !== location.href) { + console.warn(`BackHandler${this.config.key ? '-' + this.config.key : ''}: remove called while navigating, ignoring, history entry not removed. Navigation should happen after remove is called, eg: after closing the modal which triggers a navigation.`); + return; + } + this.ignoreNextBack(); + history.back(); + }); + } + } + + private ignoreNextBack() { + for (const backHandler of BackHandler.#backStack) { + backHandler.#ignoreNextBack = true; + } + } +} + +export function useBackHandler(config: BackHandlerConfig) { + new BackHandler(config); +} diff --git a/frontend/viewer/src/lib/utils/url.svelte.ts b/frontend/viewer/src/lib/utils/url.svelte.ts new file mode 100644 index 0000000000..6cc66d0fa3 --- /dev/null +++ b/frontend/viewer/src/lib/utils/url.svelte.ts @@ -0,0 +1,83 @@ +import {createSubscriber} from 'svelte/reactivity'; +import {useLocation} from 'svelte-routing'; + +export interface QueryParamStateConfig { + key: string; + allowBack?: boolean; + replaceOnDefaultValue?: boolean; +} + +/** + * + * A reactive query parameter state + * it reacts to users pressing back and svelte router changes + */ +export class QueryParamState { + #subscribe: ReturnType; + #current: string = $state(''); + public get current(): string { + this.#subscribe(); + return this.#current; + } + + public set current(value: string) { + if (value === this.#current) return; + const currentUrl = new URL(document.location.href); + const isDefault = value === this.defaultValue; + if (isDefault) { + currentUrl.searchParams.delete(this.config.key); + } else { + currentUrl.searchParams.set(this.config.key, value); + } + if (this.config.replaceOnDefaultValue && isDefault) { + const state = history.state as unknown; + const pushKey = state && typeof state === 'object' && 'pushKey' in state ? state.pushKey as string : undefined; + if (pushKey === this.config.key) { + //the last history event was push by us so we need to just go back otherwise the next back will do nothing + history.go(-1); + } else { + history.replaceState(null, '', currentUrl.href); + } + } else if (this.config.allowBack) { + history.pushState({pushKey: this.config.key}, '', currentUrl.href); + } else { + history.replaceState(null, '', currentUrl.href); + } + //history events don't trigger popstate, so we need to set the value directly + this.#current = value; + } + + constructor(private config: QueryParamStateConfig, private defaultValue: string = '') { + const location = useLocation(); + + this.#current = this.readUrlValue(); + //ensures that we only subscribe to popstate if current is being watched/used in an $effect + this.#subscribe = createSubscriber(update => { + const off = location.subscribe(() => { + this.#current = this.readUrlValue(); + update(); + }); + return () => off(); + }); + } + + private readUrlValue(): string { + return new URL(document.location.href).searchParams.get(this.config.key) ?? this.defaultValue; + } +} + +export class QueryParamStateBool { + #stringState: QueryParamState; + + get current(): boolean { + return this.#stringState.current === 'true'; + } + + set current(value: boolean) { + this.#stringState.current = value.toString(); + } + + constructor(config: QueryParamStateConfig, defaultValue: boolean = false) { + this.#stringState = new QueryParamState(config, defaultValue.toString()); + } +} diff --git a/frontend/viewer/src/lib/views/view-data.ts b/frontend/viewer/src/lib/views/view-data.ts index dba3a59937..21ce400157 100644 --- a/frontend/viewer/src/lib/views/view-data.ts +++ b/frontend/viewer/src/lib/views/view-data.ts @@ -88,9 +88,11 @@ function recursiveSpread>(obj1: T, ob return result as T; } +export type ViewType = 'fw-lite' | 'fw-classic'; + interface ViewDefinition { id: string; - type: 'fw-lite' | 'fw-classic'; + type: ViewType; i18nKey: I18nType; label: string; } diff --git a/frontend/viewer/src/lib/views/view-text.ts b/frontend/viewer/src/lib/views/view-text.ts new file mode 100644 index 0000000000..21b993b29b --- /dev/null +++ b/frontend/viewer/src/lib/views/view-text.ts @@ -0,0 +1,41 @@ +import type {View, ViewType} from './view-data'; + +export type ViewText = string | {lite: string, classic: string}; + +export function viewText(classicText: string, liteText?: string): ViewText { + if (liteText) { + return {lite: liteText, classic: classicText}; + } + return classicText; +} + +export function pickViewText(classicText: string, liteText: string, type: ViewType | View): string +export function pickViewText(viewText: ViewText, type: ViewType | View): string +export function pickViewText(viewText: ViewText, typeOrLite: string | View, typeOrNothing?: ViewType | View): string { + const type = pickViewType(typeOrNothing ? typeOrNothing : typeOrLite as ViewType | View); + if (typeOrNothing) { + return pickViewTextInternal(viewText as string, typeOrLite as string, type); + } else if (typeof viewText === 'string') { + return viewText; + } + return pickViewTextInternal(viewText.classic, viewText.lite, type); +} + +function pickViewTextInternal(classicText: string, liteText: string | undefined, type: ViewType): string { + if (liteText && type === 'fw-lite') { + return liteText; + } + return classicText; +} + +function pickViewType(type: ViewType | View): ViewType { + if (typeof type === 'string') { + return type; + } + return type.type; +} + +export { + viewText as vt, + pickViewText as pt, +}; diff --git a/frontend/viewer/src/lib/writing-system-runes.svelte.ts b/frontend/viewer/src/lib/writing-system-runes.svelte.ts deleted file mode 100644 index edeb15002b..0000000000 --- a/frontend/viewer/src/lib/writing-system-runes.svelte.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {untrack} from 'svelte'; -import type {IWritingSystems} from './dotnet-types'; -import {useMiniLcmApi} from './services/service-provider'; -import {initWritingSystemService, WritingSystemService} from './writing-system-service'; -import {writable} from 'svelte/store'; - -//todo this won't work when projects change, the WSS should depend on miniLcmApi which should also be reactive -let writingSystems = $state< IWritingSystems | null>(null); -let loading = $state(false); -export function useWritingSystemRunes(): WritingSystemService { - if (!writingSystems){ - untrack(load); - } - - return new WritingSystemService(writingSystems ?? {analysis: [], vernacular: []}); -} - -function load() { - if (loading) return; - const wsStore = writable(null); - initWritingSystemService(wsStore); - loading = true; - void useMiniLcmApi().getWritingSystems().then(ws => { - console.log('Writing systems loaded:', ws); - writingSystems = ws; - wsStore.set(ws); - loading = false; - }); -} \ No newline at end of file diff --git a/frontend/viewer/src/lib/writing-system-service.ts b/frontend/viewer/src/lib/writing-system-service.svelte.ts similarity index 79% rename from frontend/viewer/src/lib/writing-system-service.ts rename to frontend/viewer/src/lib/writing-system-service.svelte.ts index cfcb67322f..fb9f0b43e0 100644 --- a/frontend/viewer/src/lib/writing-system-service.ts +++ b/frontend/viewer/src/lib/writing-system-service.svelte.ts @@ -1,32 +1,32 @@ import type {IEntry, IExampleSentence, IMultiString, ISense, IWritingSystem, IWritingSystems} from '$lib/dotnet-types'; -import {getContext, onDestroy, setContext} from 'svelte'; -import {derived, get, type Readable} from 'svelte/store'; import type {WritingSystemSelection} from './config-types'; import {firstTruthy} from './utils'; +import {type ProjectContext, useProjectContext} from '$lib/project-context.svelte'; +import {type ResourceReturn} from 'runed'; -const writingSystemContextName = 'writingSystems'; -export function initWritingSystemService(ws: Readable): Readable { - return setContext(writingSystemContextName, derived(ws, ($ws) => $ws ? new WritingSystemService($ws) : null)); -} - +const symbol = Symbol.for('fw-lite-ws-service'); export function useWritingSystemService(): WritingSystemService { - const writingSystemServiceContext = getContext>(writingSystemContextName); - if (!writingSystemServiceContext) throw new Error('Writing system context is not initialized. Are you in the context of a project?'); - const writingSystemService = get(writingSystemServiceContext); - if (!writingSystemService) throw new Error('Writing system service is not initialized.'); - const unsub = writingSystemServiceContext.subscribe((service) => { - if (service !== writingSystemService) console.warn('Writing system service changed unexpectedly.'); - }); - onDestroy(unsub); - return writingSystemService; + const projectContext = useProjectContext(); + return projectContext.getOrAdd(symbol, () => new WritingSystemService(projectContext)); } export class WritingSystemService { - private readonly wsColors: WritingSystemColors; + private wsColors: WritingSystemColors = $derived(calcWritingSystemColors(this.writingSystems)); + #wsResource: ResourceReturn; + private get writingSystems(): IWritingSystems { + return this.#wsResource.current; + } - constructor(private readonly writingSystems: IWritingSystems) { - this.wsColors = calcWritingSystemColors(writingSystems); + constructor(projectContext: ProjectContext) { + this.#wsResource = projectContext.apiResource({analysis: [], vernacular: []}, async api => { + const result = await api.getWritingSystems(); + return { + //hide audio writing systems since we don't support them for now + vernacular: result.vernacular.filter(ws => !ws.isAudio), + analysis: result.analysis.filter(ws => !ws.isAudio) + }; + }); } allWritingSystems(selection: Extract = 'vernacular-analysis'): IWritingSystem[] { @@ -41,11 +41,11 @@ export class WritingSystemService { return this.pickWritingSystems('vernacular'); } - defaultVernacular(): IWritingSystem | undefined { + get defaultVernacular(): IWritingSystem | undefined { return this.writingSystems.vernacular[0]; } - defaultAnalysis(): IWritingSystem | undefined { + get defaultAnalysis(): IWritingSystem | undefined { return this.writingSystems.analysis[0]; } @@ -72,7 +72,7 @@ export class WritingSystemService { } indexExemplars(): string[] | undefined { - return this.defaultVernacular()?.exemplars; + return this.defaultVernacular?.exemplars; } headword(entry: IEntry, ws?: string): string { diff --git a/frontend/viewer/src/lib/writing-system/WritingSystemDialog.svelte b/frontend/viewer/src/lib/writing-system/WritingSystemDialog.svelte index dda2d5a02d..f2e4f848a2 100644 --- a/frontend/viewer/src/lib/writing-system/WritingSystemDialog.svelte +++ b/frontend/viewer/src/lib/writing-system/WritingSystemDialog.svelte @@ -1,7 +1,7 @@  {#if isActive} -
+
+ (selectedEntryId.current = e?.id ?? '')} + previewDictionary={entryMode === 'preview'}/>
- (selectedEntry = e)} /> {/if} {#if !IsMobile.value} - + {/if} - {#if selectedEntry || !IsMobile.value} + {#if selectedEntryId.current || !IsMobile.value} - {#if !selectedEntry} -
-

Select an entry to view details

-
- {:else} - (selectedEntry = undefined)} - showClose={IsMobile.value} - /> - {/if} + {#if !selectedEntryId.current} +
+

{$t`Select an entry to view details`}

+
+ {:else} +
+ (selectedEntryId.current = '')} + showClose={IsMobile.value} + /> +
+ {/if}
{/if} diff --git a/frontend/viewer/src/project/browse/EntriesList.svelte b/frontend/viewer/src/project/browse/EntriesList.svelte index e311644a72..c8dca968c5 100644 --- a/frontend/viewer/src/project/browse/EntriesList.svelte +++ b/frontend/viewer/src/project/browse/EntriesList.svelte @@ -11,21 +11,40 @@ import {ScrollArea} from '$lib/components/ui/scroll-area'; import DevContent from '$lib/layout/DevContent.svelte'; import NewEntryButton from '../NewEntryButton.svelte'; + import {useDialogsService} from '$lib/services/dialogs-service'; + import {useProjectEventBus} from '$lib/services/event-bus'; + import EntryMenu from './EntryMenu.svelte'; + import FabContainer from '$lib/components/fab/fab-container.svelte'; const { search = '', - selectedEntry = undefined, + selectedEntryId = undefined, sortDirection = 'asc', onSelectEntry, gridifyFilter = undefined, + previewDictionary = false }: { search?: string; - selectedEntry?: IEntry; + selectedEntryId?: string; sortDirection: 'asc' | 'desc'; - onSelectEntry: (entry: IEntry) => void; + onSelectEntry: (entry?: IEntry) => void; gridifyFilter?: string; + previewDictionary?: boolean } = $props(); const miniLcmApi = useMiniLcmApi(); + const dialogsService = useDialogsService(); + const projectEventBus = useProjectEventBus(); + + projectEventBus.onEntryDeleted(entryId => { + if (selectedEntryId === entryId) onSelectEntry(undefined); + if (entriesResource.loading || !entries.some(e => e.id === entryId)) return; + void entriesResource.refetch(); + }); + projectEventBus.onEntryUpdated(_entry => { + if (entriesResource.loading) return; + void entriesResource.refetch(); + }); + const entriesResource = resource( () => ({ search, sortDirection, gridifyFilter }), @@ -55,33 +74,35 @@ // Generate a random number of skeleton rows between 3 and 7 const skeletonRowCount = Math.floor(Math.random() * 5) + 3; - function handleNewEntry() { - console.log('handleNewEntry'); + async function handleNewEntry() { + const entry = await dialogsService.createNewEntry(); + if (!entry) return; + onSelectEntry(entry); } -
+
+ - + {#if entriesResource.error}

{$t`Failed to load entries`}

{entriesResource.error.message}

{:else} -
+
{#if loading.current} {#each { length: skeletonRowCount }, _index} @@ -89,7 +110,12 @@ {/each} {:else} {#each entries as entry} - onSelectEntry(entry)} /> + + onSelectEntry(entry)} + {previewDictionary} /> + {:else}

{$t`No entries found`}

diff --git a/frontend/viewer/src/project/browse/EntryMenu.svelte b/frontend/viewer/src/project/browse/EntryMenu.svelte index 5d98cc2955..e18f70db20 100644 --- a/frontend/viewer/src/project/browse/EntryMenu.svelte +++ b/frontend/viewer/src/project/browse/EntryMenu.svelte @@ -1,69 +1,74 @@ + const headword = $derived((entry && writingSystemService.headword(entry)) || $t`Untitled`); -{#snippet items()} - {@render menuItem('i-mdi-delete', $t`Delete Entry`, onDelete)} - {@render menuItem('i-mdi-history', $t`History`, () => {})} -{/snippet} + let open = $state(false); -{#snippet menuItem(icon: IconClass, label: string, onSelect: () => void)} - {#if !IsMobile.value} - - - {label} - - {:else} - - {/if} -{/snippet} + async function onDelete() { + if (!await dialogsService.promptDelete($t`Entry`, headword)) return; + await miniLcmApi.deleteEntry(entry.id); + projectEventBus.notifyEntryDeleted(entry.id); + } -{#if !IsMobile.value} - - - - - - {@render items()} - - -{:else} - - - - - -
- - - -
- {@render items()} -
- - -
-
-
+ const features = useFeatures(); + let showHistoryView = $state(false); + const appLauncher = useAppLauncherService(); + +{#if features.history} + {/if} + + + + + + {pt($t`Delete Entry`, $t`Delete Word`, $currentView)} + + {#if features.history} + showHistoryView = true}> + {$t`History`} + + {/if} + {#if multiWindowService} + void multiWindowService.openEntryInNewWindow(entry.id)}> + {$t`Open in new Window`} + + {/if} + {#if features.openWithFlex && (appLauncher || !IsMobile.value)} + + {#snippet child({props})} + + {/snippet} + + {/if} + + diff --git a/frontend/viewer/src/project/browse/EntryRow.svelte b/frontend/viewer/src/project/browse/EntryRow.svelte index ba5d52e7e1..f1d87b9a60 100644 --- a/frontend/viewer/src/project/browse/EntryRow.svelte +++ b/frontend/viewer/src/project/browse/EntryRow.svelte @@ -1,16 +1,23 @@ -
+ {:else if previewDictionary} + {:else} -

{writingSystemService.headword(entry) || 'Untitled'}

+

{writingSystemService.headword(entry) || $t`Untitled`}

{#if sensePreview}
{sensePreview}
- {#if partOfSpeech} - - {writingSystemService.pickBestAlternative(partOfSpeech.name, 'analysis')} - - {/if} + {#if badge} + {@render badge()} + {:else if partOfSpeech} + + {writingSystemService.pickBestAlternative(partOfSpeech.name, 'analysis')} + + {/if} {/if}
{/if} - + diff --git a/frontend/viewer/src/project/browse/EntryView.svelte b/frontend/viewer/src/project/browse/EntryView.svelte index fb4e4065ac..4926736cc0 100644 --- a/frontend/viewer/src/project/browse/EntryView.svelte +++ b/frontend/viewer/src/project/browse/EntryView.svelte @@ -1,19 +1,24 @@ -
+{#snippet preview(entry: IEntry)} +
+ + {#snippet actions()} + sticky, (value) => dictionaryPreview = value ? 'sticky' : 'show'} + aria-label={`Toggle pinned`} class="aspect-square" size="xs"> + + + {/snippet} + +
+{/snippet} + +
{#if entry} -
- {#if showClose && onClose} - +
+
+ {#if showClose && onClose} + + {/if} +

{headword}

+
+ + +
+
+ {#if dictionaryPreview === 'sticky'} +
+ {@render preview(entry)} +
{/if} -

{writingSystemService.headword(entry) || 'Untitled'}

-
- -
- - + + {#if dictionaryPreview === 'show'} + {@render preview(entry)} + {/if} +
+ +
{/if} {#if loadingDebounced.current} diff --git a/frontend/viewer/src/project/browse/SearchFilter.svelte b/frontend/viewer/src/project/browse/SearchFilter.svelte index 63385bab21..a8548ed1ee 100644 --- a/frontend/viewer/src/project/browse/SearchFilter.svelte +++ b/frontend/viewer/src/project/browse/SearchFilter.svelte @@ -5,10 +5,8 @@ import { ComposableInput } from '$lib/components/ui/input'; import { t } from 'svelte-i18n-lingui'; import {Switch} from '$lib/components/ui/switch'; - import {Label} from '$lib/components/ui/label'; import {Toggle} from '$lib/components/ui/toggle'; import {cn} from '$lib/utils'; - import {IsMobile} from '$lib/hooks/is-mobile.svelte'; let { search = $bindable(), @@ -41,7 +39,7 @@ let filtersExpanded = $state(false); - +
{#snippet before()} diff --git a/frontend/viewer/src/project/browse/ViewPicker.svelte b/frontend/viewer/src/project/browse/ViewPicker.svelte index be1417b58f..139c62f3a2 100644 --- a/frontend/viewer/src/project/browse/ViewPicker.svelte +++ b/frontend/viewer/src/project/browse/ViewPicker.svelte @@ -1,14 +1,21 @@ - {#snippet trigger()} - + {#snippet trigger({props})} +