fix(frontend): add horizontal overflow handling to tables on mobile

Wraps all tables that were missing overflow handling in a
`has-horizontal-overflow` div to prevent horizontal overflow on mobile
viewports.

Affected components:
- Sessions.vue
- ApiTokens.vue
- ProjectSettingsWebhooks.vue
- LinkSharing.vue
- UserTeam.vue
- EditTeam.vue
- ProjectSettingsViews.vue

Fixes https://github.com/go-vikunja/vikunja/issues/2331
This commit is contained in:
kolaente
2026-03-02 08:28:56 +01:00
parent cb2647abbe
commit 15aa773212
7 changed files with 437 additions and 423 deletions

View File

@@ -67,108 +67,110 @@
</XButton>
</div>
<table
<div
v-if="linkShares.length > 0"
class="table has-actions is-striped is-hoverable is-fullwidth"
class="has-horizontal-overflow"
>
<thead>
<tr>
<th />
<th v-if="availableViews.length > 0">
{{ $t('project.share.links.view') }}
</th>
<th>{{ $t('project.share.attributes.delete') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="s in linkShares"
:key="s.id"
>
<td>
<p
v-if="s.name !== ''"
class="mbe-2 is-italic"
>
{{ s.name }}
</p>
<p class="mbe-2">
<i18n-t
keypath="project.share.links.sharedBy"
scope="global"
<table class="table has-actions is-striped is-hoverable is-fullwidth">
<thead>
<tr>
<th />
<th v-if="availableViews.length > 0">
{{ $t('project.share.links.view') }}
</th>
<th>{{ $t('project.share.attributes.delete') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="s in linkShares"
:key="s.id"
>
<td>
<p
v-if="s.name !== ''"
class="mbe-2 is-italic"
>
<strong>{{ getDisplayName(s.sharedBy) }}</strong>
</i18n-t>
</p>
{{ s.name }}
</p>
<p class="mbe-2">
<template v-if="s.permission === PERMISSIONS.ADMIN">
<span class="icon is-small">
<Icon icon="lock" />
</span>&nbsp;
{{ $t('project.share.permission.admin') }}
</template>
<template v-else-if="s.permission === PERMISSIONS.READ_WRITE">
<span class="icon is-small">
<Icon icon="pen" />
</span>&nbsp;
{{ $t('project.share.permission.readWrite') }}
</template>
<template v-else>
<span class="icon is-small">
<Icon icon="users" />
</span>&nbsp;
{{ $t('project.share.permission.read') }}
</template>
</p>
<p class="mbe-2">
<i18n-t
keypath="project.share.links.sharedBy"
scope="global"
>
<strong>{{ getDisplayName(s.sharedBy) }}</strong>
</i18n-t>
</p>
<p class="mbe-2">
<template v-if="s.permission === PERMISSIONS.ADMIN">
<span class="icon is-small">
<Icon icon="lock" />
</span>&nbsp;
{{ $t('project.share.permission.admin') }}
</template>
<template v-else-if="s.permission === PERMISSIONS.READ_WRITE">
<span class="icon is-small">
<Icon icon="pen" />
</span>&nbsp;
{{ $t('project.share.permission.readWrite') }}
</template>
<template v-else>
<span class="icon is-small">
<Icon icon="users" />
</span>&nbsp;
{{ $t('project.share.permission.read') }}
</template>
</p>
<FormField
:model-value="shareLinks[s.id]"
readonly
type="text"
>
<template #addon>
<XButton
v-tooltip="$t('misc.copy')"
:shadow="false"
@click="copy(shareLinks[s.id])"
>
<span class="icon">
<Icon icon="paste" />
</span>
</XButton>
</template>
</FormField>
</td>
<td v-if="availableViews.length > 0">
<div class="select">
<select v-model="selectedViews[s.id]">
<option
v-for="(view) in availableViews"
:key="view.id"
:value="view.id"
>
{{ view.title }}
</option>
</select>
</div>
</td>
<td class="actions">
<XButton
danger
icon="trash-alt"
@click="
() => {
linkIdToDelete = s.id
showDeleteModal = true
}
"
/>
</td>
</tr>
</tbody>
</table>
<FormField
:model-value="shareLinks[s.id]"
readonly
type="text"
>
<template #addon>
<XButton
v-tooltip="$t('misc.copy')"
:shadow="false"
@click="copy(shareLinks[s.id])"
>
<span class="icon">
<Icon icon="paste" />
</span>
</XButton>
</template>
</FormField>
</td>
<td v-if="availableViews.length > 0">
<div class="select">
<select v-model="selectedViews[s.id]">
<option
v-for="(view) in availableViews"
:key="view.id"
:value="view.id"
>
{{ view.title }}
</option>
</select>
</div>
</td>
<td class="actions">
<XButton
danger
icon="trash-alt"
@click="
() => {
linkIdToDelete = s.id
showDeleteModal = true
}
"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<Modal

View File

@@ -41,99 +41,101 @@
</div>
</div>
<table
<div
v-if="sharables.length > 0"
class="table has-actions is-striped is-hoverable is-fullwidth mbe-4"
class="has-horizontal-overflow mbe-4"
>
<tbody>
<tr
v-for="s in sharables"
:key="s.id"
>
<template v-if="shareType === 'user'">
<td>{{ getDisplayName(s) }}</td>
<td>
<template v-if="s.id === userInfo.id">
<b class="is-success">{{ $t('project.share.userTeam.you') }}</b>
<table class="table has-actions is-striped is-hoverable is-fullwidth">
<tbody>
<tr
v-for="s in sharables"
:key="s.id"
>
<template v-if="shareType === 'user'">
<td>{{ getDisplayName(s) }}</td>
<td>
<template v-if="s.id === userInfo.id">
<b class="is-success">{{ $t('project.share.userTeam.you') }}</b>
</template>
</td>
</template>
<template v-if="shareType === 'team'">
<td>
<RouterLink
:to="{
name: 'teams.edit',
params: { id: s.id },
}"
>
{{ s.name }}
</RouterLink>
</td>
</template>
<td class="type">
<template v-if="s.permission === PERMISSIONS.ADMIN">
<span class="icon is-small">
<Icon icon="lock" />
</span>
{{ $t('project.share.permission.admin') }}
</template>
<template v-else-if="s.permission === PERMISSIONS.READ_WRITE">
<span class="icon is-small">
<Icon icon="pen" />
</span>
{{ $t('project.share.permission.readWrite') }}
</template>
<template v-else>
<span class="icon is-small">
<Icon icon="users" />
</span>
{{ $t('project.share.permission.read') }}
</template>
</td>
</template>
<template v-if="shareType === 'team'">
<td>
<RouterLink
:to="{
name: 'teams.edit',
params: { id: s.id },
}"
>
{{ s.name }}
</RouterLink>
<td
v-if="userIsAdmin"
class="actions"
>
<div class="select">
<select
v-model="selectedPermission[s.id]"
class="mie-2"
@change="toggleType(s)"
>
<option
:selected="s.permission === PERMISSIONS.READ"
:value="PERMISSIONS.READ"
>
{{ $t('project.share.permission.read') }}
</option>
<option
:selected="s.permission === PERMISSIONS.READ_WRITE"
:value="PERMISSIONS.READ_WRITE"
>
{{ $t('project.share.permission.readWrite') }}
</option>
<option
:selected="s.permission === PERMISSIONS.ADMIN"
:value="PERMISSIONS.ADMIN"
>
{{ $t('project.share.permission.admin') }}
</option>
</select>
</div>
<XButton
danger
icon="trash-alt"
@click="
() => {
sharable = s
showDeleteModal = true
}
"
/>
</td>
</template>
<td class="type">
<template v-if="s.permission === PERMISSIONS.ADMIN">
<span class="icon is-small">
<Icon icon="lock" />
</span>
{{ $t('project.share.permission.admin') }}
</template>
<template v-else-if="s.permission === PERMISSIONS.READ_WRITE">
<span class="icon is-small">
<Icon icon="pen" />
</span>
{{ $t('project.share.permission.readWrite') }}
</template>
<template v-else>
<span class="icon is-small">
<Icon icon="users" />
</span>
{{ $t('project.share.permission.read') }}
</template>
</td>
<td
v-if="userIsAdmin"
class="actions"
>
<div class="select">
<select
v-model="selectedPermission[s.id]"
class="mie-2"
@change="toggleType(s)"
>
<option
:selected="s.permission === PERMISSIONS.READ"
:value="PERMISSIONS.READ"
>
{{ $t('project.share.permission.read') }}
</option>
<option
:selected="s.permission === PERMISSIONS.READ_WRITE"
:value="PERMISSIONS.READ_WRITE"
>
{{ $t('project.share.permission.readWrite') }}
</option>
<option
:selected="s.permission === PERMISSIONS.ADMIN"
:value="PERMISSIONS.ADMIN"
>
{{ $t('project.share.permission.admin') }}
</option>
</select>
</div>
<XButton
danger
icon="trash-alt"
@click="
() => {
sharable = s
showDeleteModal = true
}
"
/>
</td>
</tr>
</tbody>
</table>
</tr>
</tbody>
</table>
</div>
<Nothing v-else>
{{ $t('project.share.userTeam.notShared', {type: shareTypeNames}) }}

View File

@@ -151,68 +151,70 @@ async function saveViewPosition(e) {
{{ $t('project.views.onlyAdminsCanEdit') }}
</Message>
<table
<div
v-if="views?.length > 0"
class="table has-actions is-striped is-hoverable is-fullwidth"
class="has-horizontal-overflow"
>
<thead>
<tr>
<th>{{ $t('project.views.title') }}</th>
<th>{{ $t('project.views.kind') }}</th>
<th class="has-text-end">
{{ $t('project.views.actions') }}
</th>
</tr>
</thead>
<draggable
v-model="views"
tag="tbody"
item-key="id"
handle=".handle"
:animation="100"
@end="saveViewPosition"
>
<template #item="{element: v}">
<table class="table has-actions is-striped is-hoverable is-fullwidth">
<thead>
<tr>
<template v-if="viewToEdit !== null && viewToEdit.id === v.id">
<td colspan="3">
<ViewEditForm
v-model="viewToEdit"
class="mbe-4"
:loading="projectViewService.loading"
:show-save-buttons="true"
@cancel="viewToEdit = null"
@update:modelValue="saveView(viewToEdit)"
/>
</td>
</template>
<template v-else>
<td>{{ v.title }}</td>
<td>{{ v.viewKind }}</td>
<td class="has-text-end actions">
<XButton
v-if="isAdmin"
class="is-danger mie-2"
icon="trash-alt"
@click="() => {
viewIdToDelete = v.id
showDeleteModal = true
}"
/>
<XButton
v-if="isAdmin"
icon="pen"
@click="viewToEdit = {...v}"
/>
<span class="icon handle">
<Icon icon="grip-lines" />
</span>
</td>
</template>
<th>{{ $t('project.views.title') }}</th>
<th>{{ $t('project.views.kind') }}</th>
<th class="has-text-end">
{{ $t('project.views.actions') }}
</th>
</tr>
</template>
</draggable>
</table>
</thead>
<draggable
v-model="views"
tag="tbody"
item-key="id"
handle=".handle"
:animation="100"
@end="saveViewPosition"
>
<template #item="{element: v}">
<tr>
<template v-if="viewToEdit !== null && viewToEdit.id === v.id">
<td colspan="3">
<ViewEditForm
v-model="viewToEdit"
class="mbe-4"
:loading="projectViewService.loading"
:show-save-buttons="true"
@cancel="viewToEdit = null"
@update:modelValue="saveView(viewToEdit)"
/>
</td>
</template>
<template v-else>
<td>{{ v.title }}</td>
<td>{{ v.viewKind }}</td>
<td class="has-text-end actions">
<XButton
v-if="isAdmin"
class="is-danger mie-2"
icon="trash-alt"
@click="() => {
viewIdToDelete = v.id
showDeleteModal = true
}"
/>
<XButton
v-if="isAdmin"
icon="pen"
@click="viewToEdit = {...v}"
/>
<span class="icon handle">
<Icon icon="grip-lines" />
</span>
</td>
</template>
</tr>
</template>
</draggable>
</table>
</div>
</CreateEdit>
<Modal

View File

@@ -246,44 +246,46 @@ function validateSelectedEvents() {
</XButton>
</div>
<table
<div
v-if="webhooks?.length > 0"
class="table has-actions is-striped is-hoverable is-fullwidth"
class="has-horizontal-overflow"
>
<thead>
<tr>
<th>{{ $t('project.webhooks.targetUrl') }}</th>
<th>{{ $t('project.webhooks.events') }}</th>
<th>{{ $t('misc.created') }}</th>
<th>{{ $t('misc.createdBy') }}</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="w in webhooks"
:key="w.id"
>
<td>{{ w.targetUrl }}</td>
<td>{{ w.events.join(', ') }}</td>
<td>{{ formatDateShort(w.created) }}</td>
<td>
<User
:avatar-size="25"
:user="w.createdBy"
/>
</td>
<table class="table has-actions is-striped is-hoverable is-fullwidth">
<thead>
<tr>
<th>{{ $t('project.webhooks.targetUrl') }}</th>
<th>{{ $t('project.webhooks.events') }}</th>
<th>{{ $t('misc.created') }}</th>
<th>{{ $t('misc.createdBy') }}</th>
<th />
</tr>
</thead>
<tbody>
<tr
v-for="w in webhooks"
:key="w.id"
>
<td>{{ w.targetUrl }}</td>
<td>{{ w.events.join(', ') }}</td>
<td>{{ formatDateShort(w.created) }}</td>
<td>
<User
:avatar-size="25"
:user="w.createdBy"
/>
</td>
<td class="actions">
<XButton
danger
icon="trash-alt"
@click="() => {showDeleteModal = true;webhookIdToDelete = w.id}"
/>
</td>
</tr>
</tbody>
</table>
<td class="actions">
<XButton
danger
icon="trash-alt"
@click="() => {showDeleteModal = true;webhookIdToDelete = w.id}"
/>
</td>
</tr>
</tbody>
</table>
</div>
<Modal
:enabled="showDeleteModal"

View File

@@ -109,61 +109,63 @@
{{ $t('team.edit.mustSelectUser') }}
</p>
</form>
<table class="table has-actions is-striped is-hoverable is-fullwidth">
<tbody>
<tr
v-for="m in team?.members"
:key="m.id"
>
<td>
<User
:avatar-size="24"
:user="m"
class="m-0"
/>
</td>
<td>
<template v-if="m.id === userInfo.id">
<b class="is-success">You</b>
</template>
</td>
<td class="type">
<template v-if="m.admin">
<span class="icon is-small">
<Icon icon="lock" />
</span>
{{ $t('team.attributes.admin') }}
</template>
<template v-else>
<span class="icon is-small">
<Icon icon="user" />
</span>
{{ $t('team.attributes.member') }}
</template>
</td>
<td
v-if="userIsAdmin"
class="actions"
<div class="has-horizontal-overflow">
<table class="table has-actions is-striped is-hoverable is-fullwidth">
<tbody>
<tr
v-for="m in team?.members"
:key="m.id"
>
<XButton
v-if="m.id !== userInfo.id"
:loading="teamMemberService.loading"
class="mie-2"
@click="() => toggleUserType(m)"
<td>
<User
:avatar-size="24"
:user="m"
class="m-0"
/>
</td>
<td>
<template v-if="m.id === userInfo.id">
<b class="is-success">You</b>
</template>
</td>
<td class="type">
<template v-if="m.admin">
<span class="icon is-small">
<Icon icon="lock" />
</span>
{{ $t('team.attributes.admin') }}
</template>
<template v-else>
<span class="icon is-small">
<Icon icon="user" />
</span>
{{ $t('team.attributes.member') }}
</template>
</td>
<td
v-if="userIsAdmin"
class="actions"
>
{{ m.admin ? $t('team.edit.makeMember') : $t('team.edit.makeAdmin') }}
</XButton>
<XButton
v-if="m.id !== userInfo.id"
:loading="teamMemberService.loading"
danger
icon="trash-alt"
@click="() => {memberToDelete = m; showUserDeleteModal = true}"
/>
</td>
</tr>
</tbody>
</table>
<XButton
v-if="m.id !== userInfo.id"
:loading="teamMemberService.loading"
class="mie-2"
@click="() => toggleUserType(m)"
>
{{ m.admin ? $t('team.edit.makeMember') : $t('team.edit.makeAdmin') }}
</XButton>
<XButton
v-if="m.id !== userInfo.id"
:loading="teamMemberService.loading"
danger
icon="trash-alt"
@click="() => {memberToDelete = m; showUserDeleteModal = true}"
/>
</td>
</tr>
</tbody>
</table>
</div>
</Card>
<XButton

View File

@@ -217,60 +217,62 @@ function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
.
</p>
<table
<div
v-if="tokens.length > 0"
class="table"
class="has-horizontal-overflow"
>
<thead>
<tr>
<th>{{ $t('misc.id') }}</th>
<th>{{ $t('user.settings.apiTokens.attributes.title') }}</th>
<th>{{ $t('user.settings.apiTokens.attributes.permissions') }}</th>
<th>{{ $t('user.settings.apiTokens.attributes.expiresAt') }}</th>
<th>{{ $t('misc.created') }}</th>
<th class="has-text-end">
{{ $t('misc.actions') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="tk in tokens"
:key="tk.id"
>
<td>{{ tk.id }}</td>
<td>{{ tk.title }}</td>
<td class="is-capitalized">
<template
v-for="(v, p) in tk.permissions"
:key="'permission-' + p"
>
<strong>{{ formatPermissionTitle(p) }}:</strong>
{{ v.map(formatPermissionTitle).join(', ') }}
<br>
</template>
</td>
<td>
{{ formatDisplayDate(tk.expiresAt) }}
<p
v-if="tk.expiresAt < new Date()"
class="has-text-danger"
>
{{ $t('user.settings.apiTokens.expired', {ago: formatDateSince(tk.expiresAt)}) }}
</p>
</td>
<td>{{ formatDisplayDate(tk.created) }}</td>
<td class="has-text-end">
<XButton
variant="secondary"
@click="() => {tokenToDelete = tk; showDeleteModal = true}"
>
{{ $t('misc.delete') }}
</XButton>
</td>
</tr>
</tbody>
</table>
<table class="table">
<thead>
<tr>
<th>{{ $t('misc.id') }}</th>
<th>{{ $t('user.settings.apiTokens.attributes.title') }}</th>
<th>{{ $t('user.settings.apiTokens.attributes.permissions') }}</th>
<th>{{ $t('user.settings.apiTokens.attributes.expiresAt') }}</th>
<th>{{ $t('misc.created') }}</th>
<th class="has-text-end">
{{ $t('misc.actions') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="tk in tokens"
:key="tk.id"
>
<td>{{ tk.id }}</td>
<td>{{ tk.title }}</td>
<td class="is-capitalized">
<template
v-for="(v, p) in tk.permissions"
:key="'permission-' + p"
>
<strong>{{ formatPermissionTitle(p) }}:</strong>
{{ v.map(formatPermissionTitle).join(', ') }}
<br>
</template>
</td>
<td>
{{ formatDisplayDate(tk.expiresAt) }}
<p
v-if="tk.expiresAt < new Date()"
class="has-text-danger"
>
{{ $t('user.settings.apiTokens.expired', {ago: formatDateSince(tk.expiresAt)}) }}
</p>
</td>
<td>{{ formatDisplayDate(tk.created) }}</td>
<td class="has-text-end">
<XButton
variant="secondary"
@click="() => {tokenToDelete = tk; showDeleteModal = true}"
>
{{ $t('misc.delete') }}
</XButton>
</td>
</tr>
</tbody>
</table>
</div>
<form
v-if="showCreateForm"

View File

@@ -45,48 +45,50 @@ async function deleteSession() {
{{ $t('user.settings.sessions.description') }}
</p>
<table
<div
v-if="sessions.length > 0"
class="table"
class="has-horizontal-overflow"
>
<thead>
<tr>
<th>{{ $t('user.settings.sessions.deviceInfo') }}</th>
<th>{{ $t('user.settings.sessions.ipAddress') }}</th>
<th>{{ $t('user.settings.sessions.lastActive') }}</th>
<th class="has-text-end">
{{ $t('misc.actions') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="session in sessions"
:key="session.id"
>
<td>
{{ session.deviceInfo }}
<span
v-if="session.id === authStore.currentSessionId"
class="tag is-primary mis-2"
>
{{ $t('user.settings.sessions.current') }}
</span>
</td>
<td>{{ session.ipAddress }}</td>
<td>{{ formatDateSince(session.lastActive) }}</td>
<td class="has-text-end">
<XButton
v-if="session.id !== authStore.currentSessionId"
variant="secondary"
@click="confirmDelete(session)"
>
{{ $t('misc.delete') }}
</XButton>
</td>
</tr>
</tbody>
</table>
<table class="table">
<thead>
<tr>
<th>{{ $t('user.settings.sessions.deviceInfo') }}</th>
<th>{{ $t('user.settings.sessions.ipAddress') }}</th>
<th>{{ $t('user.settings.sessions.lastActive') }}</th>
<th class="has-text-end">
{{ $t('misc.actions') }}
</th>
</tr>
</thead>
<tbody>
<tr
v-for="session in sessions"
:key="session.id"
>
<td>
{{ session.deviceInfo }}
<span
v-if="session.id === authStore.currentSessionId"
class="tag is-primary mis-2"
>
{{ $t('user.settings.sessions.current') }}
</span>
</td>
<td>{{ session.ipAddress }}</td>
<td>{{ formatDateSince(session.lastActive) }}</td>
<td class="has-text-end">
<XButton
v-if="session.id !== authStore.currentSessionId"
variant="secondary"
@click="confirmDelete(session)"
>
{{ $t('misc.delete') }}
</XButton>
</td>
</tr>
</tbody>
</table>
</div>
<p v-else>
{{ $t('user.settings.sessions.noOtherSessions') }}