diff --git a/frontend/package.json b/frontend/package.json index 4cd51209e..5c6a5b15a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -73,8 +73,10 @@ "@opentelemetry/sdk-node": "^0.39.1", "@opentelemetry/sdk-trace-web": "^1.13.0", "@opentelemetry/semantic-conventions": "^1.13.0", + "@types/js-cookie": "^3.0.6", "@vitejs/plugin-basic-ssl": "^1.0.1", "css-tree": "^2.3.1", + "js-cookie": "^3.0.5", "mjml": "^4.14.1", "svelte-exmarkdown": "^3.0.1", "svelte-intl-precompile": "^0.12.3", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 1abbcd573..b9bb2ec16 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -35,12 +35,18 @@ dependencies: '@opentelemetry/semantic-conventions': specifier: ^1.13.0 version: 1.13.0 + '@types/js-cookie': + specifier: ^3.0.6 + version: 3.0.6 '@vitejs/plugin-basic-ssl': specifier: ^1.0.1 version: 1.0.1(vite@4.4.9) css-tree: specifier: ^2.3.1 version: 2.3.1 + js-cookie: + specifier: ^3.0.5 + version: 3.0.5 mjml: specifier: ^4.14.1 version: 4.14.1 @@ -3651,6 +3657,10 @@ packages: '@types/node': 20.4.4 dev: false + /@types/js-cookie@3.0.6: + resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} + dev: false + /@types/js-yaml@4.0.5: resolution: {integrity: sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA==} dev: true @@ -5915,6 +5925,11 @@ packages: nopt: 6.0.0 dev: false + /js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} + dev: false + /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 479169f3d..248437393 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -5,6 +5,7 @@ import { loadI18n } from '$lib/i18n'; import { ensureErrorIsTraced, traceRequest, traceFetch } from '$lib/otel/otel.server' import { env } from '$env/dynamic/private'; import { getErrorMessage, validateFetchResponse } from './hooks.shared'; +import {setViewMode} from './routes/(authenticated)/shared'; const UNAUTHENTICATED_ROOT = '(unauthenticated)'; const AUTHENTICATED_ROOT = '(authenticated)'; @@ -39,6 +40,10 @@ export const handle: Handle = ({ event, resolve }) => { } else if (!isAuthn(cookies)) { throw redirect(307, '/login'); } + //when at home + if (routeId == `/${AUTHENTICATED_ROOT}`) { + setViewMode(event.params, cookies); + } return resolve(event, options); }) diff --git a/frontend/src/lib/app.postcss b/frontend/src/lib/app.postcss index 22f898774..e43b17991 100644 --- a/frontend/src/lib/app.postcss +++ b/frontend/src/lib/app.postcss @@ -64,6 +64,9 @@ form { .text-accent { @apply !text-accent; } + .text-success { + @apply !text-success; + } } .pale { @@ -151,3 +154,7 @@ table tr:nth-last-child(-n + 2):not(:nth-child(-n + 2)) .dropdown { } } } + +.header-actions .btn { + @apply btn-sm sm:btn-md; +} diff --git a/frontend/src/lib/components/Dropdown.svelte b/frontend/src/lib/components/Dropdown.svelte index 0bb6aa74c..d35da43a6 100644 --- a/frontend/src/lib/components/Dropdown.svelte +++ b/frontend/src/lib/components/Dropdown.svelte @@ -26,9 +26,9 @@ class:dropdown-right={right} class:dropdown-hover={hover}> diff --git a/frontend/src/lib/components/EditableText.svelte b/frontend/src/lib/components/EditableText.svelte index 6faeaf479..71bd98430 100644 --- a/frontend/src/lib/components/EditableText.svelte +++ b/frontend/src/lib/components/EditableText.svelte @@ -87,7 +87,7 @@ {#if editing || saving} - + {/if} diff --git a/frontend/src/lib/components/FilterBar/FilterBar.svelte b/frontend/src/lib/components/FilterBar/FilterBar.svelte index fd3216c07..31c6f6188 100644 --- a/frontend/src/lib/components/FilterBar/FilterBar.svelte +++ b/frontend/src/lib/components/FilterBar/FilterBar.svelte @@ -1,51 +1,62 @@ -
+
diff --git a/frontend/src/lib/components/IconButton.svelte b/frontend/src/lib/components/IconButton.svelte index 9ead2ff63..2821a697d 100644 --- a/frontend/src/lib/components/IconButton.svelte +++ b/frontend/src/lib/components/IconButton.svelte @@ -6,9 +6,9 @@ export let icon: `i-mdi-${string}`; export let disabled = false; export let loading = false; - export let style: CssClassList<'btn-success', 'btn-ghost' | 'btn-outline'> = 'btn-outline'; export let active = false; export let join = false; + export let style: CssClassList<'btn-success', 'btn-ghost' | 'btn-outline'> = 'btn-outline'; diff --git a/frontend/src/lib/components/Paging/index.ts b/frontend/src/lib/components/Paging/index.ts new file mode 100644 index 000000000..008d21c31 --- /dev/null +++ b/frontend/src/lib/components/Paging/index.ts @@ -0,0 +1,9 @@ +export const DEFAULT_PAGE_SIZE = 100; + +export function limit(items: T[], take = DEFAULT_PAGE_SIZE): T[] { + return page(items, 0, take); +} + +export function page(item: T[], skip = 0, take = DEFAULT_PAGE_SIZE): T[] { + return item.slice(skip, skip + take); +} diff --git a/frontend/src/lib/components/ProjectList.svelte b/frontend/src/lib/components/ProjectList.svelte index 247b53ff9..ded264b21 100644 --- a/frontend/src/lib/components/ProjectList.svelte +++ b/frontend/src/lib/components/ProjectList.svelte @@ -1,16 +1,15 @@
{#each projects as project} - +

@@ -39,23 +38,8 @@

{/each} - - {#if showCreateButton} - -
- - {$t('project.create.title')} -
-
- {/if}
-{#if !showCreateButton && !projects.length} -
- {$t('user_dashboard.no_projects')} -
-{/if} - diff --git a/frontend/src/lib/components/Projects/ProjectFilter.svelte b/frontend/src/lib/components/Projects/ProjectFilter.svelte new file mode 100644 index 000000000..3e1482140 --- /dev/null +++ b/frontend/src/lib/components/Projects/ProjectFilter.svelte @@ -0,0 +1,144 @@ + + + + + + + {#each activeFilters as filter} + {#if filter.key === 'projectType'} + + + + {:else if filter.key === 'showDeletedProjects'} + + + {$t('project.filter.show_deleted')} + + {:else if filter.key === 'userEmail' && filter.value} + + + {filter.value} + + {:else if filter.key === 'migrationStatus'} + + {filter.value} + + {/if} + {/each} + + +

{$t('project.filter.title')}

+ {#if filterEnabled('userEmail')} + + {#if $filters.userEmail} +
+ +
+ ($filters.userEmail = undefined)} /> +
+
+ {:else} +
+ +
+ {$t('project.filter.select_user_from_table')} + + + + + + + {$t('project.filter.filter_user_projects')} + +
+
+ {/if} +
+ {/if} + {#if filterEnabled('projectType')} +
+ +
+ {/if} + {#if filterEnabled('migrationStatus')} +
+ +
+ {/if} + {#if filterEnabled('showDeletedProjects')} +
+ +
+ {/if} +
+
diff --git a/frontend/src/lib/components/Projects/ProjectTable.svelte b/frontend/src/lib/components/Projects/ProjectTable.svelte new file mode 100644 index 000000000..8ebf9a00d --- /dev/null +++ b/frontend/src/lib/components/Projects/ProjectTable.svelte @@ -0,0 +1,115 @@ + + +
+ + + + {#if isColumnVisible('name')} + + {/if} + {#if isColumnVisible('code')} + + {/if} + {#if isColumnVisible('users')} + + {/if} + {#if isColumnVisible('lastChange')} + + {/if} + {#if isColumnVisible('migrated')} + + {/if} + {#if isColumnVisible('type')} + + {/if} + {#if $$slots.actions} + + + + {#each projects as project} + + {#if isColumnVisible('name')} + + {/if} + {#if isColumnVisible('code')} + + {/if} + {#if isColumnVisible('users')} + + {/if} + {#if isColumnVisible('lastChange')} + + {/if} + {#if isColumnVisible('migrated')} + + {/if} + {#if isColumnVisible('type')} + + {/if} + {#if $$slots.actions} + + {/if} + + {/each} + +
{$t('project.table.name')}{$t('project.table.code')}{$t('project.table.users')} + {$t('project.table.last_change')} + + {$t('project.table.migrated')}{$t('project.table.type')} + {/if} +
+ {#if project.deletedDate} + + {project.name} + + + {:else} + + {project.name} + + {/if} + {project.code}{project.userCount} + {#if project.deletedDate} + + + + {:else} + + {/if} + + + + +
+
diff --git a/frontend/src/lib/components/Projects/index.ts b/frontend/src/lib/components/Projects/index.ts new file mode 100644 index 000000000..ef8701d9b --- /dev/null +++ b/frontend/src/lib/components/Projects/index.ts @@ -0,0 +1,3 @@ +export { default as ProjectFilter } from './ProjectFilter.svelte'; +export { default as ProjectTable } from '../Projects/ProjectTable.svelte'; +export * from './ProjectFilter.svelte'; diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 332335329..3ae709506 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -11,19 +11,8 @@ "column_type": "Type", "column_users": "Users", "column_edit": "Edit", - "show_delete_projects": "Show deleted projects", - "filter_placeholder": "Search...", - "project_filter": { - "title": "Project filters", - "select_user_from_table": "To filter projects for a specific user, select the user from the user table:", - "project_member": "Project member", - "all_users": "All users", - "show_deleted": "Show deleted", - }, - "filter_projects": "Filter projects", "project_table_title": "Projects", "user_table_title": "Users", - "email_used": "An account with this email address already exists", "email_not_verified": "Not Verified", "notifications": { "user_deleted": "{name} has been deleted.", @@ -47,7 +36,6 @@ } } }, - "load_more": "Load more" }, "account_settings": { "title": "Account Settings", @@ -56,6 +44,7 @@ "reset_password": "Reset your password instead?", "update_success": "Your account has been updated.", "button_update": "Update account info", + "email_taken": "An account with this email address already exists", "delete_account": { "title": "Delete Account", "submit": "Delete Account", @@ -147,7 +136,22 @@ the [Linguistics Institute at Payap University](https://li.payap.ac.th/) in Chia "submit": "Create Project", "requested": "Your request for project {name} has been submitted.", "request": "Request Project" - } + }, + "table": { + "name": "Name", + "code": "Code", + "last_change": "Last Change", + "migrated": "Migrated", + "type": "Type", + "users": "Users", + }, + "filter": { + "title": "Project filters", + "show_deleted": "Show deleted", + "select_user_from_table": "To filter projects for a specific user, select the user from the user table:", + "project_member": "Project member", + "filter_user_projects": "Filter projects", + }, }, "project_page": { "project": "Project", @@ -207,7 +211,6 @@ the [Linguistics Institute at Payap University](https://li.payap.ac.th/) in Chia "last_commit": "Last Commit", "members": "Members", "add_description": "Add description...", - "not_found": "Project {code} not found!", "remove_project_user_title": "Member", "remove_user": "Remove", "change_role": "Change Role", @@ -284,7 +287,8 @@ the [Linguistics Institute at Payap University](https://li.payap.ac.th/) in Chia "user_dashboard": { "home_title": "Home", "title": "My Projects", - "no_projects": "You aren't in any projects yet. You have to verify your email address before you can join or request a project." + "not_verified": "You aren't in any projects yet. You have to verify your email address before you can join or request a project.", + "no_projects": "You aren't in any projects yet.", }, "user_types": { "admin": "Admin", @@ -365,4 +369,10 @@ the [Linguistics Institute at Payap University](https://li.payap.ac.th/) in Chia "ZIP_MISSING_HG_FOLDER": "The ZIP file does not contain a folder named .hg" } }, + "paging": { + "load_more": "Load more", + }, + "filter": { + "placeholder": "Search...", + }, } diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index c97b0f60e..581175564 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -46,7 +46,6 @@ "history": "Historia", "last_commit": "Última confirmación", "members": "Miembros del proyecto", - "not_found": "¡Proyecto {code} no encontrado!" }, "project_role": { "editor": "Editor", diff --git a/frontend/src/lib/layout/Breadcrumbs.svelte b/frontend/src/lib/layout/Breadcrumbs.svelte index b7a8cb67f..2b6391c1a 100644 --- a/frontend/src/lib/layout/Breadcrumbs.svelte +++ b/frontend/src/lib/layout/Breadcrumbs.svelte @@ -25,7 +25,8 @@ create: 'project.create.title', _get: () => { const data = $page.data as ProjectPageData; - return data.project ? get(data.project)?.name ?? data.code : data.code; + // eslint-disable-next-line svelte/require-store-reactive-access + return data.project ? get(data.project)?.name ?? $page.params['project_code'] : $page.params['project_code']; }, }, user: 'account_settings.title', diff --git a/frontend/src/lib/layout/HeaderPage.svelte b/frontend/src/lib/layout/HeaderPage.svelte new file mode 100644 index 000000000..e9889d7ce --- /dev/null +++ b/frontend/src/lib/layout/HeaderPage.svelte @@ -0,0 +1,27 @@ + + + + + +
+
+ +
+
+ {#if $$slots.title} + + {:else} + {title} + {/if} +
+
+ +
+
+ + diff --git a/frontend/src/lib/layout/Page.svelte b/frontend/src/lib/layout/Page.svelte index 79373d05b..45fe50015 100644 --- a/frontend/src/lib/layout/Page.svelte +++ b/frontend/src/lib/layout/Page.svelte @@ -1,18 +1,23 @@ -{#if $$slots.header} - - - +{#if title} + {/if} -
- -
+
+ {#if $$slots.header} + + {/if} + +
+ +
+
diff --git a/frontend/src/lib/layout/PageHeader.svelte b/frontend/src/lib/layout/PageHeader.svelte deleted file mode 100644 index 0c8bad9f8..000000000 --- a/frontend/src/lib/layout/PageHeader.svelte +++ /dev/null @@ -1,14 +0,0 @@ - - -

- -

diff --git a/frontend/src/lib/layout/PageTitle.svelte b/frontend/src/lib/layout/PageTitle.svelte new file mode 100644 index 000000000..4b3cb004d --- /dev/null +++ b/frontend/src/lib/layout/PageTitle.svelte @@ -0,0 +1,11 @@ + + + + +

+ {title} +

diff --git a/frontend/src/lib/layout/SetTitle.svelte b/frontend/src/lib/layout/SetTitle.svelte new file mode 100644 index 000000000..c05caf472 --- /dev/null +++ b/frontend/src/lib/layout/SetTitle.svelte @@ -0,0 +1,7 @@ + + + + {title} + diff --git a/frontend/src/lib/layout/TitlePage.svelte b/frontend/src/lib/layout/TitlePage.svelte new file mode 100644 index 000000000..80443e8e5 --- /dev/null +++ b/frontend/src/lib/layout/TitlePage.svelte @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/frontend/src/lib/layout/index.ts b/frontend/src/lib/layout/index.ts index 1053a730c..0ef9acc4f 100644 --- a/frontend/src/lib/layout/index.ts +++ b/frontend/src/lib/layout/index.ts @@ -1,12 +1,14 @@ -import Layout from './Layout.svelte' import AdminContent from './AdminContent.svelte' import AppBar from './AppBar.svelte' import AppMenu from './AppMenu.svelte' import Breadcrumbs from './Breadcrumbs.svelte' import Content from './Content.svelte' import Footer from './Footer.svelte' +import HeaderPage from './HeaderPage.svelte' +import Layout from './Layout.svelte' import Page from './Page.svelte' -import PageHeader from './PageHeader.svelte' +import PageTitle from './PageTitle.svelte' +import TitlePage from './TitlePage.svelte' export { Layout, @@ -16,6 +18,8 @@ export { Breadcrumbs, Content, AdminContent, - PageHeader, Footer, + PageTitle, + TitlePage, + HeaderPage, } diff --git a/frontend/src/routes/(authenticated)/+page.svelte b/frontend/src/routes/(authenticated)/+page.svelte index f44d1aec0..edee88998 100644 --- a/frontend/src/routes/(authenticated)/+page.svelte +++ b/frontend/src/routes/(authenticated)/+page.svelte @@ -2,17 +2,108 @@ import type { PageData } from './$types'; import t from '$lib/i18n'; import ProjectList from '$lib/components/ProjectList.svelte'; - import {Page} from '$lib/layout'; + import { HeaderPage } from '$lib/layout'; + import { getSearchParams, queryParam } from '$lib/util/query-params'; + import type { ProjectType } from '$lib/gql/types'; + import { ProjectFilter, filterProjects, type ProjectFilters, type ProjectItem } from '$lib/components/Projects'; + import ProjectTable from '$lib/components/Projects/ProjectTable.svelte'; + import { Button } from '$lib/forms'; + import { limit } from '$lib/components/Paging'; + import IconButton from '$lib/components/IconButton.svelte'; + import Cookies from 'js-cookie' + import { STORAGE_VIEW_MODE_KEY, ViewMode } from './shared'; export let data: PageData; $: projects = data.projects; + type Filters = Pick; + + const { queryParamValues: filters, defaultQueryParamValues: defaultFilterValues } = getSearchParams({ + projectSearch: queryParam.string(''), + projectType: queryParam.string(undefined), + }); + + let initializedMode = false; + let mode: ViewMode; + $: defaultMode = $projects.length < 10 ? ViewMode.Grid : ViewMode.Table; + + $: { + if (!initializedMode) { + const storedMode = data.projectViewMode; + if (storedMode === ViewMode.Table || storedMode === ViewMode.Grid) { + mode = storedMode; + } else { + mode = defaultMode; + } + initializedMode = true; + } + } + + function selectMode(selectedMode: ViewMode): void { + mode = selectedMode; + Cookies.set(STORAGE_VIEW_MODE_KEY, mode, { expires: 365 * 10 }); + } + + let filteredProjects: ProjectItem[] = []; + let limitResults = true; + $: filteredProjects = filterProjects($projects, $filters); + $: shownProjects = limitResults ? limit(filteredProjects) : filteredProjects; - - - {$t('user_dashboard.title')} + + +
+
+ (limitResults = true)} + filterKeys={['projectSearch', 'projectType']} + /> +
+
+ selectMode(ViewMode.Grid)} /> + selectMode(ViewMode.Table)} /> +
+
+
+ + {#if data.user.emailVerified} + + + {$t('project.create.title')} + + {/if} - -
+ {#if !data.user.emailVerified || !$projects.length} +
+ + {#if !data.user.emailVerified} + {$t('user_dashboard.not_verified')} + {:else} + {$t('user_dashboard.no_projects')} + {/if} +
+ {:else} + {#if mode === ViewMode.Grid} + + {:else} + + {/if} + + {#if shownProjects.length < filteredProjects.length} + + {/if} + {/if} + diff --git a/frontend/src/routes/(authenticated)/+page.ts b/frontend/src/routes/(authenticated)/+page.ts index 677fa8f0f..015dbbbee 100644 --- a/frontend/src/routes/(authenticated)/+page.ts +++ b/frontend/src/routes/(authenticated)/+page.ts @@ -1,10 +1,9 @@ import { getClient, graphql } from '$lib/gql'; import type { PageLoadEvent } from './$types'; +import {getViewMode} from './shared'; export async function load(event: PageLoadEvent) { - // TODO: Invalidate this load() when user ID changes, so that logging out and logging in fetches a different project list - // Currently Svelte-Kit is skipping re-running this load if you log out and back in, which results in stale project lists const client = getClient(); //language=GraphQL const results = await client.awaitedQueryStore(event.fetch, graphql(` @@ -19,7 +18,11 @@ export async function load(event: PageLoadEvent) { } } `), {}); + + const projectViewMode = getViewMode(event); + return { projects: results.myProjects, + projectViewMode, } } diff --git a/frontend/src/routes/(authenticated)/admin/+page.svelte b/frontend/src/routes/(authenticated)/admin/+page.svelte index 929ca92f7..a167bda77 100644 --- a/frontend/src/routes/(authenticated)/admin/+page.svelte +++ b/frontend/src/routes/(authenticated)/admin/+page.svelte @@ -12,12 +12,12 @@ import Dropdown from '$lib/components/Dropdown.svelte'; import { RefineFilterMessage } from '$lib/components/Table'; import type { AdminSearchParams, User } from './+page'; - import ProjectTable from './ProjectTable.svelte'; import { getSearchParams, queryParam } from '$lib/util/query-params'; import type { ProjectType, ProjectMigrationStatus } from '$lib/gql/types'; + import AdminProjects from './AdminProjects.svelte'; export let data: PageData; - $: allProjects = data.projects; + $: projects = data.projects; $: userData = data.users; const { notifySuccess, notifyWarning } = useNotifications(); @@ -30,18 +30,16 @@ projectSearch: queryParam.string(''), migrationStatus: queryParam.string(undefined), }); - const { queryParamValues } = queryParams; $: users = $userData?.items ?? []; - $: totalUsers = $userData?.totalCount ?? 0; + $: filteredUserCount = $userData?.totalCount ?? 0; $: shownUsers = $queryParamValues.userSearch ? users : users.slice(0, 10); function filterProjectsByUser(user: User): void { $queryParamValues.userEmail = user.email; } - let projectsTable: ProjectTable; let deleteUserModal: DeleteUserModal; let formModal: EditUserAccount; @@ -65,13 +63,12 @@ name: user.name, requestedEmail: formState.email.currentValue, }), - Duration.Long + Duration.Long, ); } } } - @@ -79,7 +76,8 @@
- + +
@@ -88,11 +86,11 @@ {shownUsers.length} / - {totalUsers} + {filteredUserCount} - +
@@ -133,22 +131,22 @@ {user.isAdmin ? $t('user_types.admin') : $t('user_types.user')} - + -
diff --git a/frontend/src/routes/(authenticated)/admin/+page.ts b/frontend/src/routes/(authenticated)/admin/+page.ts index 05219ab0d..c2215b273 100644 --- a/frontend/src/routes/(authenticated)/admin/+page.ts +++ b/frontend/src/routes/(authenticated)/admin/+page.ts @@ -10,20 +10,14 @@ import type { ChangeUserAccountByAdminMutation, ProjectFilterInput, UserFilterInput, - ProjectType, - ProjectMigrationStatus } from '$lib/gql/types'; import type {LoadAdminDashboardProjectsQuery, LoadAdminDashboardUsersQuery} from '$lib/gql/types'; +import type { ProjectFilters } from '$lib/components/Projects'; +import { DEFAULT_PAGE_SIZE } from '$lib/components/Paging'; -export const _FILTER_PAGE_SIZE = 100; - -export type AdminSearchParams = { - userSearch: string, - showDeletedProjects: boolean, - projectType: ProjectType | undefined, - userEmail: string | undefined, - projectSearch: string, - migrationStatus: ProjectMigrationStatus | 'UNMIGRATED' | undefined, +// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents -- false positive? +export type AdminSearchParams = ProjectFilters & { + userSearch: string }; export type Project = LoadAdminDashboardProjectsQuery['projects'][number]; @@ -90,7 +84,7 @@ export async function load(event: PageLoadEvent) { } } } - `), { filter: userFilter, take: _FILTER_PAGE_SIZE }); + `), { filter: userFilter, take: DEFAULT_PAGE_SIZE }); const [projectResults, userResults] = await Promise.all([projectResultsPromise, userResultsPromise]); diff --git a/frontend/src/routes/(authenticated)/admin/AdminProjects.svelte b/frontend/src/routes/(authenticated)/admin/AdminProjects.svelte new file mode 100644 index 000000000..a8f745303 --- /dev/null +++ b/frontend/src/routes/(authenticated)/admin/AdminProjects.svelte @@ -0,0 +1,104 @@ + + + +
+
+ + {$t('admin_dashboard.project_table_title')} + + + {shownProjects.length} + / + {filteredProjects.length} + + + + + + {$t('project.create.title')} + + + +
+ +
+ (limitResults = true)} + /> +
+ +
+ + + + {#if !project.deletedDate} + + + + + + {/if} + + + + {#if shownProjects.length < filteredProjects.length} + {#if hasActiveFilter} + + {:else} + + {/if} + {/if} +
diff --git a/frontend/src/routes/(authenticated)/admin/EditUserAccount.svelte b/frontend/src/routes/(authenticated)/admin/EditUserAccount.svelte index fa248d740..9e6256a90 100644 --- a/frontend/src/routes/(authenticated)/admin/EditUserAccount.svelte +++ b/frontend/src/routes/(authenticated)/admin/EditUserAccount.svelte @@ -41,7 +41,7 @@ role: $form.role, }); if (data?.changeUserAccountByAdmin.errors?.some(e => e.__typename === 'UniqueValueError')) { - return {email: [$t('admin_dashboard.email_used')]}; + return {email: [$t('account_settings.email_taken')]}; } if (error) { return error.message; diff --git a/frontend/src/routes/(authenticated)/admin/ProjectTable.svelte b/frontend/src/routes/(authenticated)/admin/ProjectTable.svelte deleted file mode 100644 index a888b785a..000000000 --- a/frontend/src/routes/(authenticated)/admin/ProjectTable.svelte +++ /dev/null @@ -1,263 +0,0 @@ - - - -
-
- - {$t('admin_dashboard.project_table_title')} - - - {shownProjects.length} - / - {filteredProjects.length} - - - - - - {$t('project.create.title')} - - - -
- - - - {#each activeFilters as filter} - {#if filter.key === 'projectType'} - - - - {:else if filter.key === 'showDeletedProjects'} - - - {$t('admin_dashboard.project_filter.show_deleted')} - - {:else if filter.key === 'userEmail' && filter.value} - - - {filter.value} - - {:else if filter.key === 'migrationStatus'} - - {filter.value} - - {/if} - {/each} - - -

{$t('admin_dashboard.project_filter.title')}

- - {#if $filters.userEmail} -
- -
- $filters.userEmail = undefined} - /> -
-
- {:else} -
- -
- {$t('admin_dashboard.project_filter.select_user_from_table')} - - - - - - - {$t('admin_dashboard.filter_projects')} - -
-
- {/if} -
-
- -
-
- -
-
- -
-
-
- -
-
- - - - - - - - - - - - - {#each shownProjects as project} - - - - - - - - - - {/each} - -
{$t('admin_dashboard.column_name')}{$t('admin_dashboard.column_code')}{$t('admin_dashboard.column_users')} - {$t('admin_dashboard.column_last_change')} - - {$t('admin_dashboard.column_migrated')}{$t('admin_dashboard.column_type')} -
- {#if project.deletedDate} - - {project.name} - - - {:else} - - {project.name} - - {/if} - {project.code}{project.userCount} - {#if project.deletedDate} - - - - {:else} - - {/if} - - - - - - {#if !project.deletedDate} - - - - - - {/if} -
- {#if hasActiveProjectFilter && projectFilterLimit < filteredProjects.length} - - {/if} -
-
diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte b/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte index 7fd0f692a..d9d52875a 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte +++ b/frontend/src/routes/(authenticated)/project/[project_code]/+page.svelte @@ -28,7 +28,7 @@ import { _deleteProject } from '$lib/gql/mutations'; import { goto } from '$app/navigation'; import MoreSettings from '$lib/components/MoreSettings.svelte'; - import { AdminContent, Page } from '$lib/layout'; + import { AdminContent, HeaderPage } from '$lib/layout'; import Markdown from 'svelte-exmarkdown'; import { ProjectMigrationStatus, ProjectRole, ResetStatus } from '$lib/gql/generated/graphql'; import { onMount } from 'svelte'; @@ -39,7 +39,6 @@ $: user = data.user; let projectStore = data.project; $: project = $projectStore; - $: _project = project as NonNullable; $: changesetStore = data.changesets; $: projectHgUrl = import.meta.env.DEV @@ -61,14 +60,14 @@ $t('project_page.notifications.role_change', { name: projectUser.user.name, role: projectUser.role.toLowerCase(), - }) + }), ); } } let resetProjectModal: ResetProjectModal; async function resetProject(): Promise { - await resetProjectModal.open(_project.code, _project.resetStatus); + await resetProjectModal.open(project.code, project.resetStatus); } let removeUserModal: DeleteModal; @@ -76,7 +75,7 @@ async function deleteProjectUser(projectUser: ProjectUser): Promise { userToDelete = projectUser; const deleted = await removeUserModal.prompt(async () => { - const { error } = await _deleteProjectUser(_project.id, projectUser.user.id); + const { error } = await _deleteProjectUser(project.id, projectUser.user.id); return error?.message; }); if (deleted) { @@ -85,7 +84,7 @@ } async function updateProjectName(newName: string): Promise { - const result = await _changeProjectName({ projectId: _project.id, name: newName }); + const result = await _changeProjectName({ projectId: project.id, name: newName }); if (result.error) { return result.error.message; } @@ -94,7 +93,7 @@ async function updateProjectDescription(newDescription: string): Promise { const result = await _changeProjectDescription({ - projectId: _project.id, + projectId: project.id, description: newDescription, }); if (result.error) { @@ -104,7 +103,7 @@ } $: userId = user.id; - $: canManage = isAdmin(user) || project?.users.find(u => u.user.id == userId)?.role == ProjectRole.Manager; + $: canManage = isAdmin(user) || project?.users.find((u) => u.user.id == userId)?.role == ProjectRole.Manager; const projectNameValidation = z.string().min(1, $t('project_page.project_name_empty_error')); @@ -126,12 +125,12 @@ let deleteProjectModal: ConfirmDeleteModal; async function softDeleteProject(): Promise { - const result = await deleteProjectModal.open(_project.name, async () => { - const { error } = await _deleteProject(_project.id); + const result = await deleteProjectModal.open(project.name, async () => { + const { error } = await _deleteProject(project.id); return error?.message; }); if (result.response === DialogResponse.Submit) { - notifyWarning($t('delete_project_modal.success', { name: _project.name, code: _project.code })); + notifyWarning($t('delete_project_modal.success', { name: project.name, code: project.code })); await goto(data.home); } } @@ -152,7 +151,7 @@ [ProjectMigrationStatus.Unknown]: undefined, [ProjectMigrationStatus.PrivateRedmine]: undefined, [ProjectMigrationStatus.PublicRedmine]: undefined, - } satisfies Record; + } satisfies Record; const migrationStatusBadgeVariant = { [ProjectMigrationStatus.Migrated]: 'badge-success', [ProjectMigrationStatus.Migrating]: 'badge-warning', @@ -185,110 +184,109 @@ } - - {project?.name ?? $t('project_page.not_found', { code: data.code })} - - - -
- {#if project} + +{#if project} + + {#if migrationStatus === ProjectMigrationStatus.Migrating} -
- - This project is currently being migrated. Some features may not work as expected. -
+
+ + This project is currently being migrated. Some features may not work as expected. +
{/if} -
-
- {#if migrationStatus !== ProjectMigrationStatus.Migrating} - - - -
-
-
- + + {#if migrationStatus !== ProjectMigrationStatus.Migrating} + + + +
+
+
+ +
+ + +
+ -
- - -
- + {#if copiedToClipboard} + + {:else} + -
- {#if copiedToClipboard} - - {:else} - - {/if} -
-
-
-
-
-
-
- {/if} -
-
- {$t('project_page.project')}: - - - -
- - - - - - - {migrationStatusTable[migrationStatus]} - - {#if project.resetStatus === ResetStatus.InProgress} - - {/if} - + {/if} +
+
+ + +
+
+ + {/if} +
+ +
+ {$t('project_page.project')}: + + +
- -
- + + + + + + + + + + {migrationStatusTable[migrationStatus]} + + + {#if project.resetStatus === ResetStatus.InProgress} + + {/if} + + +

{$t('project_page.summary')}

-
{$t('project_page.project_code')}: @@ -319,9 +317,7 @@ {#each project.users as member} {@const canManageMember = canManage && (member.user.id !== userId || isAdmin(user))} - +
- +
+ +{/if} diff --git a/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts b/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts index b455d27c7..5f710c9eb 100644 --- a/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts +++ b/frontend/src/routes/(authenticated)/project/[project_code]/+page.ts @@ -11,10 +11,11 @@ import type { DeleteProjectUserMutation, ProjectPageQuery, } from '$lib/gql/types'; +import { derived, get, type Readable } from 'svelte/store'; import { getClient, graphql } from '$lib/gql'; import type { PageLoadEvent } from './$types'; -import { derived } from 'svelte/store'; +import { error } from '@sveltejs/kit'; type Project = NonNullable; export type ProjectUser = Project['users'][number]; @@ -70,9 +71,14 @@ export async function load(event: PageLoadEvent) { { projectCode } ); + if (!projectResult.projectByCode || !get(projectResult.projectByCode)) { + throw error(404); + } + event.depends(`project:${projectCode}`); + return { - project: projectResult.projectByCode, + project: projectResult.projectByCode as Readable, changesets: derived(changesetResultStore, result => ({ fetching: result.fetching, changesets: result.data?.projectByCode?.changesets ?? [], diff --git a/frontend/src/routes/(authenticated)/project/create/+page.svelte b/frontend/src/routes/(authenticated)/project/create/+page.svelte index c8a5df6fd..8e1000cf5 100644 --- a/frontend/src/routes/(authenticated)/project/create/+page.svelte +++ b/frontend/src/routes/(authenticated)/project/create/+page.svelte @@ -7,7 +7,7 @@ import Select from '$lib/forms/Select.svelte'; import { CreateProjectResult, DbErrorCode, ProjectType, RetentionPolicy, type CreateProjectInput } from '$lib/gql/types'; import t from '$lib/i18n'; - import { Page } from '$lib/layout'; + import { TitlePage } from '$lib/layout'; import { z } from 'zod'; import { _createProject } from './+page'; import AdminContent from '$lib/layout/AdminContent.svelte'; @@ -123,11 +123,7 @@ } - - - {$t('project.create.title')} - - +
-
+ diff --git a/frontend/src/routes/(authenticated)/resetPassword/+page.svelte b/frontend/src/routes/(authenticated)/resetPassword/+page.svelte index 3057d4c4c..4d70cf12a 100644 --- a/frontend/src/routes/(authenticated)/resetPassword/+page.svelte +++ b/frontend/src/routes/(authenticated)/resetPassword/+page.svelte @@ -2,7 +2,7 @@ import { goto } from '$app/navigation'; import { SubmitButton, Form, FormError, Input, lexSuperForm } from '$lib/forms'; import t from '$lib/i18n'; - import Page from '$lib/layout/Page.svelte'; + import { TitlePage } from '$lib/layout'; import { hash } from '$lib/util/hash'; import { z } from 'zod'; import { useNotifications } from '$lib/notify'; @@ -30,10 +30,7 @@ }); - - - {$t('reset_password.title')} - +
{$t('reset_password.submit')}
-
+ diff --git a/frontend/src/routes/(authenticated)/shared.ts b/frontend/src/routes/(authenticated)/shared.ts new file mode 100644 index 000000000..0f0cb0460 --- /dev/null +++ b/frontend/src/routes/(authenticated)/shared.ts @@ -0,0 +1,21 @@ +import {browser} from '$app/environment'; +import Cookies from 'js-cookie'; +import type {LoadEvent, Cookies as KitCookies, RequestEvent} from '@sveltejs/kit'; + +export const STORAGE_VIEW_MODE_KEY = 'projectViewMode'; +export function getViewMode(event: LoadEvent): ViewMode | undefined { + if (browser) { + return Cookies.get(STORAGE_VIEW_MODE_KEY) as ViewMode | undefined; + } else { + //stored in params by hooks.server.ts calling setViewMode + return event.params[STORAGE_VIEW_MODE_KEY] as ViewMode | undefined; + } +} + +export function setViewMode(params: RequestEvent['params'], cookies: KitCookies): void { + params[STORAGE_VIEW_MODE_KEY] = cookies.get(STORAGE_VIEW_MODE_KEY); +} +export const enum ViewMode { + Table = 'table', + Grid = 'grid', +} diff --git a/frontend/src/routes/(authenticated)/user/+page.svelte b/frontend/src/routes/(authenticated)/user/+page.svelte index 173ebd5d3..1f3800549 100644 --- a/frontend/src/routes/(authenticated)/user/+page.svelte +++ b/frontend/src/routes/(authenticated)/user/+page.svelte @@ -2,7 +2,7 @@ import { useEmailResult, useRequestedEmail } from '$lib/email/EmailVerificationStatus.svelte'; import { SubmitButton, Form, FormError, Input, lexSuperForm } from '$lib/forms'; import t from '$lib/i18n'; - import { Page } from '$lib/layout'; + import { TitlePage } from '$lib/layout'; import { _changeUserAccountData } from './+page'; import { useNotifications } from '$lib/notify'; import z from 'zod'; @@ -46,7 +46,7 @@ userId: user.id, }); if (data?.changeUserAccountData.errors?.some(e => e.__typename === 'UniqueValueError')) { - $errors.email = [$t('admin_dashboard.email_used')]; + $errors.email = [$t('account_settings.email_taken')]; return; } if (error?.message) { @@ -72,10 +72,7 @@ }); - - - {$t('account_settings.title')} - +
- + diff --git a/frontend/src/routes/(unauthenticated)/forgotPassword/+page.svelte b/frontend/src/routes/(unauthenticated)/forgotPassword/+page.svelte index e9b42bf7b..74e3c1c13 100644 --- a/frontend/src/routes/(unauthenticated)/forgotPassword/+page.svelte +++ b/frontend/src/routes/(unauthenticated)/forgotPassword/+page.svelte @@ -3,7 +3,7 @@ import { ProtectedForm, Input, lexSuperForm, FormError } from '$lib/forms'; import { SubmitButton } from '$lib/forms'; import t from '$lib/i18n'; - import Page from '$lib/layout/Page.svelte'; + import { TitlePage } from '$lib/layout'; import { z } from 'zod'; type ForgotPasswordResponseErrors = { @@ -46,11 +46,7 @@ }); - - - {$t('forgot_password.title')} - - + {$t('forgot_password.send_email')} - + diff --git a/frontend/src/routes/(unauthenticated)/forgotPassword/emailSent/+page.svelte b/frontend/src/routes/(unauthenticated)/forgotPassword/emailSent/+page.svelte index 4a311a2db..82b262791 100644 --- a/frontend/src/routes/(unauthenticated)/forgotPassword/emailSent/+page.svelte +++ b/frontend/src/routes/(unauthenticated)/forgotPassword/emailSent/+page.svelte @@ -1,13 +1,9 @@ - - - {$t('forgot_password.email_sent.title')} - - +

{$t('forgot_password.email_sent.email_sent_message')}

@@ -15,4 +11,4 @@ -
+ diff --git a/frontend/src/routes/(unauthenticated)/login/+page.svelte b/frontend/src/routes/(unauthenticated)/login/+page.svelte index 7c08afded..e6b8de742 100644 --- a/frontend/src/routes/(unauthenticated)/login/+page.svelte +++ b/frontend/src/routes/(unauthenticated)/login/+page.svelte @@ -2,7 +2,7 @@ import { goto } from '$app/navigation'; import { SubmitButton, Form, FormError, Input, lexSuperForm } from '$lib/forms'; import t from '$lib/i18n'; - import { PageHeader } from '$lib/layout'; + import { PageTitle } from '$lib/layout'; import { login, logout } from '$lib/user'; import { onMount } from 'svelte'; import Markdown from 'svelte-exmarkdown'; @@ -62,7 +62,7 @@
- {$t('login.title')} + - - {$t('register.title')} - + {$t('register.button_register')} - +