refactor: add PageWrapper component

This commit is contained in:
2025-11-18 02:21:08 +02:00
parent fb905f8d15
commit 3ef545dcc9
18 changed files with 2092 additions and 2460 deletions

View File

@@ -55,17 +55,17 @@
</NcAppNavigationItem>
</template>
</NcAppNavigationItem>
</NcAppNavigationItem>
<!-- Preferences menu item -->
<NcAppNavigationItem
:name="strings.navPreferences"
:to="{ path: '/preferences' }"
:active="isPreferencesActive"
>
<template #icon>
<AccountCogIcon :size="20" />
</template>
<!-- Preferences menu item -->
<NcAppNavigationItem
:name="strings.navPreferences"
:to="{ path: '/preferences' }"
:active="isPreferencesActive"
>
<template #icon>
<AccountCogIcon :size="20" />
</template>
</NcAppNavigationItem>
</NcAppNavigationItem>
<!-- Admin menu item - only visible to admins -->

View File

@@ -0,0 +1,35 @@
<template>
<div class="page-wrapper" :class="{ 'full-width': fullWidth }">
<slot />
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
name: 'PageWrapper',
props: {
/**
* Whether to use full width or fixed width (900px max with auto margins)
*/
fullWidth: {
type: Boolean,
default: false,
},
},
})
</script>
<style scoped lang="scss">
.page-wrapper {
padding: 16px;
max-width: 900px;
margin: 0 auto;
&.full-width {
max-width: none;
margin: 0;
}
}
</style>

View File

@@ -1,431 +0,0 @@
<template>
<div class="user-inner">
<!-- Toolbar -->
<AppToolbar>
<template #left>
<div style="max-width: 320px">
<NcTextField
v-model="search"
:label="strings.searchLabel"
:placeholder="strings.searchPlaceholder"
trailing-button-icon="close"
:show-trailing-button="search !== ''"
@trailing-button-click="clearSearch"
/>
</div>
<NcButton @click="refresh" :disabled="loading">{{ strings.refresh }}</NcButton>
</template>
<template #right>
<NcButton type="secondary" @click="toggleForm">
{{ formOpen ? strings.hideForm : strings.showForm }}
</NcButton>
</template>
</AppToolbar>
<!-- Quick info / doc -->
<NcNoteCard class="mt-12" type="info">
<p v-html="strings.quickHelp"></p>
</NcNoteCard>
<!-- Add item form -->
<section v-if="formOpen" class="card mt-16">
<h3 class="card-title">{{ strings.formHeader }}</h3>
<div class="row gap-16 align-start">
<div style="max-width: 260px">
<NcTextField
v-model="name"
:label="strings.nameInputLabel"
:placeholder="strings.nameInputPlaceholder"
/>
</div>
<div style="max-width: 220px">
<NcSelect
v-model="themeLabel"
:options="themeOptionsLabels"
:input-label="strings.themeLabel"
/>
</div>
<div class="row gap-8 align-center">
<NcButton @click="addFromForm" :disabled="name.trim() === '' || loading">
{{ strings.add }}
</NcButton>
<NcButton type="tertiary" @click="clearForm" :disabled="loading">
{{ strings.clear }}
</NcButton>
</div>
</div>
<p class="mt-12">
{{ strings.livePreview }} <b>{{ previewGreeting }}</b>
</p>
</section>
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Empty state -->
<NcEmptyContent
v-else-if="filteredHellos.length === 0"
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
>
<template #action>
<NcButton @click="seedOne">{{ strings.addExample }}</NcButton>
</template>
</NcEmptyContent>
<!-- List -->
<section v-else class="mt-16">
<table>
<thead>
<tr>
<th style="width: 50%">{{ strings.colMessage }}</th>
<th style="width: 30%">{{ strings.colAt }}</th>
<th style="width: 20%">{{ strings.colActions }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(hello, idx) in filteredHellos" :key="hello.id">
<td class="ellipsis">
<span class="mono">{{ hello.message }}</span>
</td>
<td class="nowrap">
<NcDateTime v-if="hello.at" :timestamp="new Date(hello.at).valueOf()" />
<span v-else class="muted">{{ strings.never }}</span>
</td>
<td>
<div class="row gap-8">
<NcButton type="tertiary" @click="duplicate(idx)">{{ strings.duplicate }}</NcButton>
<NcButton type="error" @click="remove(idx)">{{ strings.remove }}</NcButton>
</div>
</td>
</tr>
</tbody>
</table>
<!-- Footer actions -->
<div class="row gap-12 mt-12">
<NcButton type="secondary" @click="refresh" :disabled="loading">{{
strings.refresh
}}</NcButton>
<NcButton type="secondary" @click="clearAll" :disabled="loading || hellos.length === 0">
{{ strings.clearAll }}
</NcButton>
</div>
</section>
</div>
</template>
<script>
/**
* Inner view rendered inside AppUserWrapper via <router-view>.
* Uses the Hello controller (GET/POST /hello).
*/
import NcButton from '@nextcloud/vue/components/NcButton'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import AppToolbar from '@/components/AppToolbar.vue'
import { ocs } from '@/axios'
import { t, n } from '@nextcloud/l10n'
export default {
name: 'AppUserHome',
components: {
NcButton,
NcNoteCard,
NcTextField,
NcSelect,
NcEmptyContent,
NcLoadingIcon,
NcDateTime,
AppToolbar,
},
data() {
return {
loading: false,
formOpen: true,
// Toolbar
search: '',
// Form data
name: '',
themeLabel: null,
themeOptions: [
{ label: t('forum', 'Light'), value: 'light' },
{ label: t('forum', 'Dark'), value: 'dark' },
{
label: n('forum', 'System (1 option)', 'System (%n options)', 2),
value: 'system',
},
],
// List of "hellos"
hellos: [],
strings: {
// Toolbar
searchLabel: t('forum', 'Search'),
searchPlaceholder: t('forum', 'Filter messages…'),
refresh: t('forum', 'Refresh'),
showForm: t('forum', 'Show form'),
hideForm: t('forum', 'Hide form'),
// Info
quickHelp: t(
'forum',
'Use the form to post a hello. The list shows recent hellos fetched from the server. All user-visible text is centralized in {cStart}strings{cEnd}.',
{ cStart: '<code>', cEnd: '</code>' },
undefined,
{ escape: false },
),
// Form
formHeader: t('forum', 'Say hello'),
nameInputLabel: t('forum', 'Name'),
nameInputPlaceholder: t('forum', 'e.g. Ada'),
themeLabel: t('forum', 'Theme'),
add: t('forum', 'Add'),
clear: t('forum', 'Clear'),
livePreview: t('forum', 'Preview:'),
// List
loading: t('forum', 'Loading…'),
emptyTitle: t('forum', 'No hellos yet'),
emptyDesc: t('forum', 'Try adding one using the form above.'),
addExample: t('forum', 'Add example'),
colMessage: t('forum', 'Message'),
colAt: t('forum', 'Time'),
colActions: t('forum', 'Actions'),
duplicate: t('forum', 'Duplicate'),
remove: t('forum', 'Remove'),
clearAll: t('forum', 'Clear all'),
never: t('forum', 'Never'),
},
}
},
created() {
this.refresh()
},
computed: {
themeOptionsLabels() {
return this.themeOptions.map((x) => x.label)
},
activeTheme() {
return this.themeOptions.find((x) => x.label === this.themeLabel) ?? this.themeOptions[0]
},
previewGreeting() {
const n = this.name.trim()
return n ? `Hello, ${n}!` : 'Hello!'
},
filteredHellos() {
const q = this.search.trim().toLowerCase()
if (!q) return this.hellos
return this.hellos.filter((h) => h.message.toLowerCase().includes(q))
},
},
methods: {
toggleForm() {
this.formOpen = !this.formOpen
},
clearForm() {
this.name = ''
this.themeLabel = null
},
clearSearch() {
this.search = ''
},
async refresh() {
try {
this.loading = true
// GET /hello -> { ocs: { data: { message, at } } }
const resp = await ocs.get('/hello')
const data = resp.data
if (data?.message) {
this.hellos.unshift({
id: genId(),
message: data.message,
at: data.at ?? null,
})
}
} catch (e) {
console.error('Failed to refresh', e)
} finally {
this.loading = false
}
},
async addFromForm() {
const name = this.name.trim()
if (!name) return
try {
this.loading = true
const payload = {
name,
theme: this.activeTheme.value,
items: [],
counter: 0,
}
// POST /hello -> { ocs: { data: { message, at } } }
const resp = await ocs.post('/hello', { data: payload })
const data = resp.data
const message = data?.message ?? `Hello, ${name}!`
const at = data?.at ?? new Date().toISOString()
this.hellos.unshift({ id: genId(), message, at })
this.clearForm()
this.formOpen = false
} catch (e) {
console.error('Failed to add hello', e)
} finally {
this.loading = false
}
},
duplicate(index) {
const src = this.hellos[index]
if (!src) return
this.hellos.splice(index + 1, 0, { ...src, id: genId() })
},
remove(index) {
this.hellos.splice(index, 1)
},
clearAll() {
this.hellos = []
},
seedOne() {
this.hellos.push({
id: genId(),
message: '👋 Hello example',
at: new Date().toISOString(),
})
},
},
}
function genId() {
return Math.random().toString(36).slice(2, 10)
}
</script>
<style scoped lang="scss">
.user-inner {
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;
}
.mono {
font-family: var(--font-monospace);
}
.mt-8 {
margin-top: 8px;
}
.mt-12 {
margin-top: 12px;
}
.mt-16 {
margin-top: 16px;
}
.ml-8 {
margin-left: 8px;
}
.center {
display: flex;
align-items: center;
justify-content: center;
}
.row {
display: flex;
&.align-start {
align-items: flex-start;
}
&.align-center {
align-items: center;
}
&.gap-8 {
gap: 8px;
}
&.gap-12 {
gap: 12px;
}
&.gap-16 {
gap: 16px;
}
}
.card {
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 12px;
background: var(--color-main-background);
}
.card-title {
margin: 0 0 8px 0;
font-size: 1rem;
font-weight: 600;
}
table {
width: 100%;
border-collapse: collapse;
border: 1px solid var(--color-border);
thead tr,
tr:not(:last-child) {
border-bottom: 1px solid var(--color-border);
}
thead,
tbody tr {
display: table;
width: 100%;
table-layout: fixed;
}
th,
td {
padding: 8px;
vertical-align: middle;
}
.nowrap {
white-space: nowrap;
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
</style>

View File

@@ -1,64 +1,66 @@
<template>
<div class="categories-view">
<header class="page-header">
<h2>{{ forumTitle }}</h2>
<p class="muted">{{ forumSubtitle }}</p>
</header>
<PageWrapper :full-width="true">
<div class="categories-view">
<header class="page-header">
<h2>{{ forumTitle }}</h2>
<p class="muted">{{ forumSubtitle }}</p>
</header>
<!-- Toolbar -->
<AppToolbar>
<template #left>
<h2 class="view-title">{{ strings.title }}</h2>
</template>
<!-- Toolbar -->
<AppToolbar>
<template #left>
<h2 class="view-title">{{ strings.title }}</h2>
</template>
<template #right>
<NcButton
@click="refresh"
:disabled="loading"
:aria-label="strings.refresh"
:title="strings.refresh"
>
<template #icon>
<RefreshIcon :size="20" />
</template>
</NcButton>
</template>
</AppToolbar>
<template #right>
<NcButton
@click="refresh"
:disabled="loading"
:aria-label="strings.refresh"
:title="strings.refresh"
>
<template #icon>
<RefreshIcon :size="20" />
</template>
</NcButton>
</template>
</AppToolbar>
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Empty state -->
<NcEmptyContent
v-else-if="categoryHeaders.length === 0"
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
/>
<!-- Categories list -->
<section v-else class="mt-16">
<div v-for="header in categoryHeaders" :key="header.id" class="header-section">
<h3 class="header-title">{{ header.name }}</h3>
<!-- Categories grid -->
<div v-if="header.categories && header.categories.length > 0" class="categories-grid">
<CategoryCard
v-for="category in header.categories"
:key="category.id"
:category="category"
@click="navigateToCategory(category)"
/>
</div>
<!-- Empty state for header with no categories -->
<p v-else class="no-categories muted">{{ strings.noCategories }}</p>
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
</section>
</div>
<!-- Empty state -->
<NcEmptyContent
v-else-if="categoryHeaders.length === 0"
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
/>
<!-- Categories list -->
<section v-else class="mt-16">
<div v-for="header in categoryHeaders" :key="header.id" class="header-section">
<h3 class="header-title">{{ header.name }}</h3>
<!-- Categories grid -->
<div v-if="header.categories && header.categories.length > 0" class="categories-grid">
<CategoryCard
v-for="category in header.categories"
:key="category.id"
:category="category"
@click="navigateToCategory(category)"
/>
</div>
<!-- Empty state for header with no categories -->
<p v-else class="no-categories muted">{{ strings.noCategories }}</p>
</div>
</section>
</div>
</PageWrapper>
</template>
<script lang="ts">
@@ -67,6 +69,7 @@ import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import AppToolbar from '@/components/AppToolbar.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import CategoryCard from '@/components/CategoryCard.vue'
import RefreshIcon from '@icons/Refresh.vue'
import { useCategories } from '@/composables/useCategories'
@@ -81,6 +84,7 @@ export default defineComponent({
NcEmptyContent,
NcLoadingIcon,
AppToolbar,
PageWrapper,
CategoryCard,
RefreshIcon,
},

View File

@@ -1,100 +1,102 @@
<template>
<div class="category-view">
<!-- Toolbar -->
<AppToolbar>
<template #left>
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ strings.back }}
</NcButton>
</template>
<PageWrapper :full-width="true">
<div class="category-view">
<!-- Toolbar -->
<AppToolbar>
<template #left>
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ strings.back }}
</NcButton>
</template>
<template #right>
<NcButton
@click="refresh"
:disabled="loading"
:aria-label="strings.refresh"
:title="strings.refresh"
>
<template #icon>
<RefreshIcon :size="20" />
</template>
</NcButton>
<NcButton @click="createThread" :disabled="loading" variant="primary">
<template #icon>
<MessagePlusIcon :size="20" />
</template>
{{ strings.newThread }}
</NcButton>
</template>
</AppToolbar>
<template #right>
<NcButton
@click="refresh"
:disabled="loading"
:aria-label="strings.refresh"
:title="strings.refresh"
>
<template #icon>
<RefreshIcon :size="20" />
</template>
</NcButton>
<NcButton @click="createThread" :disabled="loading" variant="primary">
<template #icon>
<MessagePlusIcon :size="20" />
</template>
{{ strings.newThread }}
</NcButton>
</template>
</AppToolbar>
<!-- Category Header -->
<div v-if="category && !loading" class="category-header mt-16">
<h2 class="category-name">{{ category.name }}</h2>
<p v-if="category.description" class="category-description">{{ category.description }}</p>
</div>
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">
<template #icon>
<RefreshIcon :size="20" />
</template>
{{ strings.retry }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Empty state -->
<NcEmptyContent
v-else-if="threads.length === 0"
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
>
<template #action>
<NcButton @click="createThread" variant="primary">
<template #icon>
<MessagePlusIcon :size="20" />
</template>
{{ strings.newThread }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Threads list -->
<section v-else class="mt-16">
<div class="threads-list">
<ThreadCard
v-for="thread in sortedThreads"
:key="thread.id"
:thread="thread"
:is-unread="isThreadUnread(thread)"
@click="navigateToThread(thread)"
/>
<!-- Category Header -->
<div v-if="category && !loading" class="category-header mt-16">
<h2 class="category-name">{{ category.name }}</h2>
<p v-if="category.description" class="category-description">{{ category.description }}</p>
</div>
<!-- Pagination info -->
<div v-if="threads.length >= limit" class="pagination-info mt-16">
<p class="muted">{{ strings.showingThreads(threads.length) }}</p>
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
</section>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">
<template #icon>
<RefreshIcon :size="20" />
</template>
{{ strings.retry }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Empty state -->
<NcEmptyContent
v-else-if="threads.length === 0"
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
>
<template #action>
<NcButton @click="createThread" variant="primary">
<template #icon>
<MessagePlusIcon :size="20" />
</template>
{{ strings.newThread }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Threads list -->
<section v-else class="mt-16">
<div class="threads-list">
<ThreadCard
v-for="thread in sortedThreads"
:key="thread.id"
:thread="thread"
:is-unread="isThreadUnread(thread)"
@click="navigateToThread(thread)"
/>
</div>
<!-- Pagination info -->
<div v-if="threads.length >= limit" class="pagination-info mt-16">
<p class="muted">{{ strings.showingThreads(threads.length) }}</p>
</div>
</section>
</div>
</PageWrapper>
</template>
<script lang="ts">
@@ -103,6 +105,7 @@ import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import AppToolbar from '@/components/AppToolbar.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import ThreadCard from '@/components/ThreadCard.vue'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import RefreshIcon from '@icons/Refresh.vue'
@@ -118,6 +121,7 @@ export default defineComponent({
NcEmptyContent,
NcLoadingIcon,
AppToolbar,
PageWrapper,
ThreadCard,
ArrowLeftIcon,
RefreshIcon,

View File

@@ -11,41 +11,43 @@
</template>
</AppToolbar>
<div class="create-thread-view">
<!-- Page Header -->
<div class="page-header mt-16">
<h2 class="page-title">{{ strings.title }}</h2>
<p v-if="category" class="page-subtitle">{{ strings.subtitle(category.name) }}</p>
</div>
<PageWrapper>
<div class="create-thread-view">
<!-- Page Header -->
<div class="page-header mt-16">
<h2 class="page-title">{{ strings.title }}</h2>
<p v-if="category" class="page-subtitle">{{ strings.subtitle(category.name) }}</p>
</div>
<!-- Loading state -->
<div class="center mt-16" v-if="loading && !category">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Loading state -->
<div class="center mt-16" v-if="loading && !category">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ strings.back }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ strings.back }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Create Thread Form -->
<div v-else class="mt-16">
<ThreadCreateForm ref="createForm" @submit="handleCreateThread" @cancel="goBack" />
<!-- Create Thread Form -->
<div v-else class="mt-16">
<ThreadCreateForm ref="createForm" @submit="handleCreateThread" @cancel="goBack" />
</div>
</div>
</div>
</PageWrapper>
</template>
<script lang="ts">
@@ -54,6 +56,7 @@ import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import AppToolbar from '@/components/AppToolbar.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import ThreadCreateForm from '@/components/ThreadCreateForm.vue'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import type { Category, Thread } from '@/types'
@@ -68,6 +71,7 @@ export default defineComponent({
NcEmptyContent,
NcLoadingIcon,
AppToolbar,
PageWrapper,
ThreadCreateForm,
ArrowLeftIcon,
},
@@ -172,9 +176,6 @@ export default defineComponent({
<style scoped lang="scss">
.create-thread-view {
max-width: 900px;
margin: 0 auto;
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;

View File

@@ -25,129 +25,131 @@
</template>
</AppToolbar>
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">
<template #icon>
<RefreshIcon :size="20" />
</template>
{{ strings.retry }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Profile content -->
<div v-else class="profile-content mt-16">
<!-- User Header -->
<div class="user-header">
<div class="user-avatar">
<NcAvatar :user="userId" :size="80" :show-user-status="false" />
</div>
<div class="user-info">
<h2 class="user-name">{{ displayName }}</h2>
<div class="user-meta">
<span v-if="userStats && userStats.createdAt" class="meta-item">
<span class="meta-label">{{ strings.firstPost }}</span>
<NcDateTime :timestamp="userStats.createdAt * 1000" />
</span>
<span v-if="userStats && userStats.createdAt" class="meta-divider">·</span>
<span class="meta-item">
<span class="meta-label">{{ strings.threads }}</span>
<span class="meta-value">{{ userStats?.threadCount || 0 }}</span>
</span>
<span class="meta-divider">·</span>
<span class="meta-item">
<span class="meta-label">{{ strings.posts }}</span>
<span class="meta-value">{{ userStats?.postCount || 0 }}</span>
</span>
</div>
</div>
<PageWrapper>
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Tabs -->
<div class="profile-tabs mt-24">
<div class="tabs-header">
<button
class="tab-button"
:class="{ active: activeTab === 'threads' }"
@click="activeTab = 'threads'"
>
{{ strings.threads }} ({{ threads.length }})
</button>
<button
class="tab-button"
:class="{ active: activeTab === 'posts' }"
@click="activeTab = 'posts'"
>
{{ strings.replies }} ({{ posts.length }})
</button>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">
<template #icon>
<RefreshIcon :size="20" />
</template>
{{ strings.retry }}
</NcButton>
</template>
</NcEmptyContent>
<div class="tabs-content mt-16">
<!-- Threads Tab -->
<div v-if="activeTab === 'threads'" class="tab-pane">
<div v-if="loadingThreads" class="center">
<NcLoadingIcon :size="24" />
</div>
<NcEmptyContent
v-else-if="threads.length === 0"
:title="strings.noThreads"
:description="strings.noThreadsDesc"
/>
<div v-else class="threads-list">
<ThreadCard
v-for="thread in threads"
:key="thread.id"
:thread="thread"
@click="navigateToThread(thread)"
/>
<!-- Profile content -->
<div v-else class="profile-content mt-16">
<!-- User Header -->
<div class="user-header">
<div class="user-avatar">
<NcAvatar :user="userId" :size="80" :show-user-status="false" />
</div>
<div class="user-info">
<h2 class="user-name">{{ displayName }}</h2>
<div class="user-meta">
<span v-if="userStats && userStats.createdAt" class="meta-item">
<span class="meta-label">{{ strings.firstPost }}</span>
<NcDateTime :timestamp="userStats.createdAt * 1000" />
</span>
<span v-if="userStats && userStats.createdAt" class="meta-divider">·</span>
<span class="meta-item">
<span class="meta-label">{{ strings.threads }}</span>
<span class="meta-value">{{ userStats?.threadCount || 0 }}</span>
</span>
<span class="meta-divider">·</span>
<span class="meta-item">
<span class="meta-label">{{ strings.posts }}</span>
<span class="meta-value">{{ userStats?.postCount || 0 }}</span>
</span>
</div>
</div>
</div>
<!-- Posts Tab -->
<div v-if="activeTab === 'posts'" class="tab-pane">
<div v-if="loadingPosts" class="center">
<NcLoadingIcon :size="24" />
<!-- Tabs -->
<div class="profile-tabs mt-24">
<div class="tabs-header">
<button
class="tab-button"
:class="{ active: activeTab === 'threads' }"
@click="activeTab = 'threads'"
>
{{ strings.threads }} ({{ threads.length }})
</button>
<button
class="tab-button"
:class="{ active: activeTab === 'posts' }"
@click="activeTab = 'posts'"
>
{{ strings.replies }} ({{ posts.length }})
</button>
</div>
<div class="tabs-content mt-16">
<!-- Threads Tab -->
<div v-if="activeTab === 'threads'" class="tab-pane">
<div v-if="loadingThreads" class="center">
<NcLoadingIcon :size="24" />
</div>
<NcEmptyContent
v-else-if="threads.length === 0"
:title="strings.noThreads"
:description="strings.noThreadsDesc"
/>
<div v-else class="threads-list">
<ThreadCard
v-for="thread in threads"
:key="thread.id"
:thread="thread"
@click="navigateToThread(thread)"
/>
</div>
</div>
<NcEmptyContent
v-else-if="posts.length === 0"
:title="strings.noPosts"
:description="strings.noPostsDesc"
/>
<div v-else class="posts-list">
<div
v-for="post in posts"
:key="post.id"
class="post-item"
@click="navigateToPost(post)"
>
<div class="post-meta">
<span class="post-thread" v-if="post.threadTitle">
{{ strings.inThread }} <strong>{{ post.threadTitle }}</strong>
</span>
<span class="post-date">
<NcDateTime v-if="post.createdAt" :timestamp="post.createdAt * 1000" />
</span>
<!-- Posts Tab -->
<div v-if="activeTab === 'posts'" class="tab-pane">
<div v-if="loadingPosts" class="center">
<NcLoadingIcon :size="24" />
</div>
<NcEmptyContent
v-else-if="posts.length === 0"
:title="strings.noPosts"
:description="strings.noPostsDesc"
/>
<div v-else class="posts-list">
<div
v-for="post in posts"
:key="post.id"
class="post-item"
@click="navigateToPost(post)"
>
<div class="post-meta">
<span class="post-thread" v-if="post.threadTitle">
{{ strings.inThread }} <strong>{{ post.threadTitle }}</strong>
</span>
<span class="post-date">
<NcDateTime v-if="post.createdAt" :timestamp="post.createdAt * 1000" />
</span>
</div>
<div class="post-content" v-html="post.content"></div>
</div>
<div class="post-content" v-html="post.content"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</PageWrapper>
</div>
</template>
@@ -159,6 +161,7 @@ import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import AppToolbar from '@/components/AppToolbar.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import ThreadCard from '@/components/ThreadCard.vue'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import RefreshIcon from '@icons/Refresh.vue'
@@ -177,6 +180,7 @@ export default defineComponent({
NcAvatar,
NcDateTime,
AppToolbar,
PageWrapper,
ThreadCard,
ArrowLeftIcon,
RefreshIcon,
@@ -358,158 +362,154 @@ export default defineComponent({
<style scoped lang="scss">
.profile-view {
padding: 16px;
max-width: 1200px;
margin: 0 auto;
}
.center {
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
}
.ml-8 {
margin-left: 8px;
}
.mt-16 {
margin-top: 16px;
}
.mt-24 {
margin-top: 24px;
}
.muted {
color: var(--color-text-maxcontrast);
}
.user-header {
display: flex;
align-items: center;
gap: 24px;
padding: 24px;
background: var(--color-main-background);
border: 1px solid var(--color-border);
border-radius: 8px;
}
.user-info {
flex: 1;
}
.user-name {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 600;
}
.user-meta {
display: flex;
align-items: center;
gap: 8px;
color: var(--color-text-maxcontrast);
font-size: 14px;
}
.meta-label {
margin-right: 4px;
}
.meta-value {
font-weight: 600;
color: var(--color-text-light);
}
.meta-divider {
color: var(--color-text-maxcontrast);
}
.profile-tabs {
.tabs-header {
.center {
display: flex;
border-bottom: 1px solid var(--color-border);
align-items: center;
justify-content: center;
padding: 32px;
}
.tab-button {
padding: 12px 24px;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 14px;
font-weight: 500;
.ml-8 {
margin-left: 8px;
}
.mt-16 {
margin-top: 16px;
}
.mt-24 {
margin-top: 24px;
}
.muted {
color: var(--color-text-maxcontrast);
transition: all 0.2s;
border-radius: 0;
&:hover {
color: var(--color-text-light);
background: var(--color-background-hover);
}
&.active {
color: var(--color-text-light);
border-bottom-color: var(--color-text-light);
}
}
.tabs-content {
min-height: 200px;
.user-header {
display: flex;
align-items: center;
gap: 24px;
padding: 24px;
background: var(--color-main-background);
border: 1px solid var(--color-border);
border-radius: 8px;
}
}
.threads-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.posts-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.post-item {
padding: 16px;
background: var(--color-main-background);
border: 1px solid var(--color-border);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--color-background-hover);
border-color: var(--color-primary-element);
.user-info {
flex: 1;
}
}
.post-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
color: var(--color-text-maxcontrast);
}
.user-name {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 600;
}
.post-thread {
strong {
.user-meta {
display: flex;
align-items: center;
gap: 8px;
color: var(--color-text-maxcontrast);
font-size: 14px;
}
.meta-label {
margin-right: 4px;
}
.meta-value {
font-weight: 600;
color: var(--color-text-light);
}
}
.post-content {
color: var(--color-text-light);
line-height: 1.6;
.meta-divider {
color: var(--color-text-maxcontrast);
}
// Truncate long content
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
.profile-tabs {
.tabs-header {
display: flex;
border-bottom: 1px solid var(--color-border);
}
.tab-button {
padding: 12px 24px;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: var(--color-text-maxcontrast);
transition: all 0.2s;
border-radius: 0;
&:hover {
color: var(--color-text-light);
background: var(--color-background-hover);
}
&.active {
color: var(--color-text-light);
border-bottom-color: var(--color-text-light);
}
}
.tabs-content {
min-height: 200px;
}
}
.threads-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.posts-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.post-item {
padding: 16px;
background: var(--color-main-background);
border: 1px solid var(--color-border);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--color-background-hover);
border-color: var(--color-primary-element);
}
}
.post-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
color: var(--color-text-maxcontrast);
}
.post-thread {
strong {
color: var(--color-text-light);
}
}
.post-content {
color: var(--color-text-light);
line-height: 1.6;
// Truncate long content
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
}
</style>

View File

@@ -1,132 +1,134 @@
<template>
<div class="search-view">
<!-- Search Header -->
<div class="search-header">
<h2 class="search-title">{{ strings.searchTitle }}</h2>
<PageWrapper>
<div class="search-view">
<!-- Search Header -->
<div class="search-header">
<h2 class="search-title">{{ strings.searchTitle }}</h2>
<!-- Search Input -->
<div class="search-input-wrapper">
<input
v-model="searchQuery"
type="text"
:placeholder="strings.searchPlaceholder"
class="search-input"
@keydown.enter="performSearch"
/>
<NcButton variant="primary" @click="performSearch" :disabled="!canSearch || loading">
<template #icon>
<MagnifyIcon :size="20" />
</template>
{{ strings.search }}
</NcButton>
</div>
<!-- Search Options -->
<div class="search-options">
<NcCheckboxRadioSwitch v-model="searchThreads" @update:checked="onOptionsChange">
{{ strings.searchThreads }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch v-model="searchPosts" @update:checked="onOptionsChange">
{{ strings.searchPosts }}
</NcCheckboxRadioSwitch>
<NcButton variant="tertiary" @click="showSyntaxHelp = !showSyntaxHelp">
<template #icon>
<HelpCircleIcon :size="20" />
</template>
{{ strings.syntaxHelp }}
</NcButton>
</div>
<!-- Syntax Help -->
<div v-if="showSyntaxHelp" class="syntax-help">
<h3>{{ strings.searchSyntax }}</h3>
<ul>
<li><code>"exact phrase"</code> - {{ strings.helpExactPhrase }}</li>
<li><code>term1 AND term2</code> - {{ strings.helpAnd }}</li>
<li><code>term1 OR term2</code> - {{ strings.helpOr }}</li>
<li><code>(term1 OR term2) AND term3</code> - {{ strings.helpGrouping }}</li>
<li><code>-excluded</code> - {{ strings.helpExclude }}</li>
</ul>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.searching }}</span>
</div>
<!-- Error State -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="performSearch">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Empty State (no query) -->
<NcEmptyContent
v-else-if="!hasSearched"
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
>
<template #icon>
<MagnifyIcon :size="64" />
</template>
</NcEmptyContent>
<!-- No Results -->
<NcEmptyContent
v-else-if="hasSearched && threadResults.length === 0 && postResults.length === 0"
:title="strings.noResultsTitle"
:description="strings.noResultsDesc"
class="mt-16"
>
<template #icon>
<MagnifyIcon :size="64" />
</template>
</NcEmptyContent>
<!-- Results -->
<div v-else class="search-results mt-16">
<!-- Thread Results Section -->
<section v-if="searchThreads && threadResults.length > 0" class="results-section">
<h3 class="results-header">
{{ strings.threadResults(threadCount) }}
</h3>
<div class="results-list">
<SearchThreadResult
v-for="thread in threadResults"
:key="thread.id"
:thread="thread"
:query="currentQuery"
@click="navigateToThread(thread)"
<!-- Search Input -->
<div class="search-input-wrapper">
<input
v-model="searchQuery"
type="text"
:placeholder="strings.searchPlaceholder"
class="search-input"
@keydown.enter="performSearch"
/>
<NcButton variant="primary" @click="performSearch" :disabled="!canSearch || loading">
<template #icon>
<MagnifyIcon :size="20" />
</template>
{{ strings.search }}
</NcButton>
</div>
</section>
<!-- Post Results Section -->
<section v-if="searchPosts && postResults.length > 0" class="results-section mt-16">
<h3 class="results-header">
{{ strings.postResults(postCount) }}
</h3>
<div class="results-list">
<SearchPostResult
v-for="post in postResults"
:key="post.id"
:post="post"
:query="currentQuery"
/>
<!-- Search Options -->
<div class="search-options">
<NcCheckboxRadioSwitch v-model="searchThreads" @update:checked="onOptionsChange">
{{ strings.searchThreads }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch v-model="searchPosts" @update:checked="onOptionsChange">
{{ strings.searchPosts }}
</NcCheckboxRadioSwitch>
<NcButton variant="tertiary" @click="showSyntaxHelp = !showSyntaxHelp">
<template #icon>
<HelpCircleIcon :size="20" />
</template>
{{ strings.syntaxHelp }}
</NcButton>
</div>
</section>
<!-- Syntax Help -->
<div v-if="showSyntaxHelp" class="syntax-help">
<h3>{{ strings.searchSyntax }}</h3>
<ul>
<li><code>"exact phrase"</code> - {{ strings.helpExactPhrase }}</li>
<li><code>term1 AND term2</code> - {{ strings.helpAnd }}</li>
<li><code>term1 OR term2</code> - {{ strings.helpOr }}</li>
<li><code>(term1 OR term2) AND term3</code> - {{ strings.helpGrouping }}</li>
<li><code>-excluded</code> - {{ strings.helpExclude }}</li>
</ul>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.searching }}</span>
</div>
<!-- Error State -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="performSearch">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Empty State (no query) -->
<NcEmptyContent
v-else-if="!hasSearched"
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
>
<template #icon>
<MagnifyIcon :size="64" />
</template>
</NcEmptyContent>
<!-- No Results -->
<NcEmptyContent
v-else-if="hasSearched && threadResults.length === 0 && postResults.length === 0"
:title="strings.noResultsTitle"
:description="strings.noResultsDesc"
class="mt-16"
>
<template #icon>
<MagnifyIcon :size="64" />
</template>
</NcEmptyContent>
<!-- Results -->
<div v-else class="search-results mt-16">
<!-- Thread Results Section -->
<section v-if="searchThreads && threadResults.length > 0" class="results-section">
<h3 class="results-header">
{{ strings.threadResults(threadCount) }}
</h3>
<div class="results-list">
<SearchThreadResult
v-for="thread in threadResults"
:key="thread.id"
:thread="thread"
:query="currentQuery"
@click="navigateToThread(thread)"
/>
</div>
</section>
<!-- Post Results Section -->
<section v-if="searchPosts && postResults.length > 0" class="results-section mt-16">
<h3 class="results-header">
{{ strings.postResults(postCount) }}
</h3>
<div class="results-list">
<SearchPostResult
v-for="post in postResults"
:key="post.id"
:post="post"
:query="currentQuery"
/>
</div>
</section>
</div>
</div>
</div>
</PageWrapper>
</template>
<script lang="ts">
@@ -135,6 +137,7 @@ import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import PageWrapper from '@/components/PageWrapper.vue'
import MagnifyIcon from '@icons/Magnify.vue'
import HelpCircleIcon from '@icons/HelpCircle.vue'
import SearchThreadResult from '@/components/SearchThreadResult.vue'
@@ -151,6 +154,7 @@ export default defineComponent({
NcEmptyContent,
NcLoadingIcon,
NcCheckboxRadioSwitch,
PageWrapper,
MagnifyIcon,
HelpCircleIcon,
SearchThreadResult,
@@ -276,10 +280,6 @@ export default defineComponent({
<style scoped lang="scss">
.search-view {
max-width: 900px;
margin: 0 auto;
padding: 20px;
.search-header {
margin-bottom: 24px;
}

View File

@@ -1,195 +1,197 @@
<template>
<div class="thread-view">
<!-- Toolbar -->
<AppToolbar>
<template #left>
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ thread?.categoryName ? strings.backToCategory(thread.categoryName) : strings.back }}
</NcButton>
</template>
<template #right>
<!-- Subscription toggle switch -->
<NcCheckboxRadioSwitch
v-if="!loading && thread"
v-model="thread.isSubscribed"
@update:model-value="handleToggleSubscription"
type="switch"
>
<span class="icon-label">
<BellIcon :size="20" />
{{ thread.isSubscribed ? strings.subscribed : strings.subscribe }}
</span>
</NcCheckboxRadioSwitch>
<NcButton
@click="refresh"
:disabled="loading"
:aria-label="strings.refresh"
:title="strings.refresh"
>
<template #icon>
<RefreshIcon :size="20" />
</template>
</NcButton>
<!-- Moderation buttons (only visible to moderators) -->
<template v-if="canModerate && !loading">
<NcButton
@click="handleToggleLock"
:aria-label="thread?.isLocked ? strings.unlockThread : strings.lockThread"
:title="thread?.isLocked ? strings.unlockThread : strings.lockThread"
>
<PageWrapper :full-width="true">
<div class="thread-view">
<!-- Toolbar -->
<AppToolbar>
<template #left>
<NcButton @click="goBack">
<template #icon>
<LockOpenIcon v-if="thread?.isLocked" :size="20" />
<LockIcon v-else :size="20" />
</template>
</NcButton>
<NcButton
@click="handleTogglePin"
:aria-label="thread?.isPinned ? strings.unpinThread : strings.pinThread"
:title="thread?.isPinned ? strings.unpinThread : strings.pinThread"
>
<template #icon>
<PinOffIcon v-if="thread?.isPinned" :size="20" />
<PinIcon v-else :size="20" />
<ArrowLeftIcon :size="20" />
</template>
{{ thread?.categoryName ? strings.backToCategory(thread.categoryName) : strings.back }}
</NcButton>
</template>
<NcButton
@click="replyToThread"
:disabled="loading || (thread?.isLocked && !canModerate)"
variant="primary"
>
<template #icon>
<ReplyIcon :size="20" />
</template>
{{ strings.reply }}
</NcButton>
</template>
</AppToolbar>
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">
<template #icon>
<RefreshIcon :size="20" />
</template>
{{ strings.retry }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Thread Header -->
<div v-else-if="thread" class="thread-header mt-16">
<div class="thread-title-section">
<h2 class="thread-title">
<span v-if="thread.isPinned" class="badge badge-pinned" :title="strings.pinned">
<PinIcon :size="20" />
</span>
<span v-if="thread.isLocked" class="badge badge-locked" :title="strings.locked">
<LockIcon :size="20" />
</span>
{{ thread.title }}
</h2>
<div class="thread-meta">
<span class="meta-item">
<span class="meta-label">{{ strings.by }}</span>
<span class="meta-value" :class="{ 'deleted-user': thread.authorIsDeleted }">
{{ thread.authorDisplayName || thread.authorId }}
<template #right>
<!-- Subscription toggle switch -->
<NcCheckboxRadioSwitch
v-if="!loading && thread"
v-model="thread.isSubscribed"
@update:model-value="handleToggleSubscription"
type="switch"
>
<span class="icon-label">
<BellIcon :size="20" />
{{ thread.isSubscribed ? strings.subscribed : strings.subscribe }}
</span>
</span>
<span class="meta-divider">·</span>
<span class="meta-item">
<NcDateTime v-if="thread.createdAt" :timestamp="thread.createdAt * 1000" />
</span>
<span class="meta-divider">·</span>
<span class="meta-item">
<span class="stat-icon">
<EyeIcon :size="16" />
</span>
<span class="stat-label">{{ strings.views(thread.viewCount) }}</span>
</span>
</div>
</div>
</div>
</NcCheckboxRadioSwitch>
<!-- Posts list -->
<section v-if="!loading && !error && posts.length > 0" class="mt-16">
<div class="posts-list">
<PostCard
v-for="(post, index) in posts"
:key="post.id"
:ref="(el) => setPostCardRef(el, post.id)"
:post="post"
:is-first-post="index === 0"
:is-unread="isPostUnread(post)"
@reply="handleReply"
@update="handleUpdate"
@delete="handleDelete"
/>
</div>
<NcButton
@click="refresh"
:disabled="loading"
:aria-label="strings.refresh"
:title="strings.refresh"
>
<template #icon>
<RefreshIcon :size="20" />
</template>
</NcButton>
<!-- Pagination info -->
<div v-if="posts.length >= limit" class="pagination-info mt-16">
<p class="muted">{{ strings.showingPosts(posts.length) }}</p>
</div>
</section>
<!-- Moderation buttons (only visible to moderators) -->
<template v-if="canModerate && !loading">
<NcButton
@click="handleToggleLock"
:aria-label="thread?.isLocked ? strings.unlockThread : strings.lockThread"
:title="thread?.isLocked ? strings.unlockThread : strings.lockThread"
>
<template #icon>
<LockOpenIcon v-if="thread?.isLocked" :size="20" />
<LockIcon v-else :size="20" />
</template>
</NcButton>
<!-- Empty posts state (thread exists but no posts) -->
<NcEmptyContent
v-else-if="!loading && !error && thread && posts.length === 0"
:title="strings.emptyPostsTitle"
:description="strings.emptyPostsDesc"
class="mt-16"
>
<template #action>
<NcButton @click="replyToThread" variant="primary">
<template #icon>
<ReplyIcon :size="20" />
<NcButton
@click="handleTogglePin"
:aria-label="thread?.isPinned ? strings.unpinThread : strings.pinThread"
:title="thread?.isPinned ? strings.unpinThread : strings.pinThread"
>
<template #icon>
<PinOffIcon v-if="thread?.isPinned" :size="20" />
<PinIcon v-else :size="20" />
</template>
</NcButton>
</template>
{{ strings.reply }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Locked message (only shown to non-moderators) -->
<div
v-if="!loading && !error && thread && thread.isLocked && !canModerate"
class="locked-message mt-16"
>
<NcEmptyContent :title="strings.locked" :description="strings.lockedMessage">
<template #icon>
<LockIcon :size="64" />
<NcButton
@click="replyToThread"
:disabled="loading || (thread?.isLocked && !canModerate)"
variant="primary"
>
<template #icon>
<ReplyIcon :size="20" />
</template>
{{ strings.reply }}
</NcButton>
</template>
</AppToolbar>
<!-- Loading state -->
<div class="center mt-16" v-if="loading">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">
<template #icon>
<RefreshIcon :size="20" />
</template>
{{ strings.retry }}
</NcButton>
</template>
</NcEmptyContent>
</div>
<!-- Reply form (moderators can reply even when locked) -->
<PostReplyForm
v-if="!loading && !error && thread && (!thread.isLocked || canModerate)"
ref="replyForm"
@submit="handleSubmitReply"
@cancel="handleCancelReply"
/>
</div>
<!-- Thread Header -->
<div v-else-if="thread" class="thread-header mt-16">
<div class="thread-title-section">
<h2 class="thread-title">
<span v-if="thread.isPinned" class="badge badge-pinned" :title="strings.pinned">
<PinIcon :size="20" />
</span>
<span v-if="thread.isLocked" class="badge badge-locked" :title="strings.locked">
<LockIcon :size="20" />
</span>
{{ thread.title }}
</h2>
<div class="thread-meta">
<span class="meta-item">
<span class="meta-label">{{ strings.by }}</span>
<span class="meta-value" :class="{ 'deleted-user': thread.authorIsDeleted }">
{{ thread.authorDisplayName || thread.authorId }}
</span>
</span>
<span class="meta-divider">·</span>
<span class="meta-item">
<NcDateTime v-if="thread.createdAt" :timestamp="thread.createdAt * 1000" />
</span>
<span class="meta-divider">·</span>
<span class="meta-item">
<span class="stat-icon">
<EyeIcon :size="16" />
</span>
<span class="stat-label">{{ strings.views(thread.viewCount) }}</span>
</span>
</div>
</div>
</div>
<!-- Posts list -->
<section v-if="!loading && !error && posts.length > 0" class="mt-16">
<div class="posts-list">
<PostCard
v-for="(post, index) in posts"
:key="post.id"
:ref="(el) => setPostCardRef(el, post.id)"
:post="post"
:is-first-post="index === 0"
:is-unread="isPostUnread(post)"
@reply="handleReply"
@update="handleUpdate"
@delete="handleDelete"
/>
</div>
<!-- Pagination info -->
<div v-if="posts.length >= limit" class="pagination-info mt-16">
<p class="muted">{{ strings.showingPosts(posts.length) }}</p>
</div>
</section>
<!-- Empty posts state (thread exists but no posts) -->
<NcEmptyContent
v-else-if="!loading && !error && thread && posts.length === 0"
:title="strings.emptyPostsTitle"
:description="strings.emptyPostsDesc"
class="mt-16"
>
<template #action>
<NcButton @click="replyToThread" variant="primary">
<template #icon>
<ReplyIcon :size="20" />
</template>
{{ strings.reply }}
</NcButton>
</template>
</NcEmptyContent>
<!-- Locked message (only shown to non-moderators) -->
<div
v-if="!loading && !error && thread && thread.isLocked && !canModerate"
class="locked-message mt-16"
>
<NcEmptyContent :title="strings.locked" :description="strings.lockedMessage">
<template #icon>
<LockIcon :size="64" />
</template>
</NcEmptyContent>
</div>
<!-- Reply form (moderators can reply even when locked) -->
<PostReplyForm
v-if="!loading && !error && thread && (!thread.isLocked || canModerate)"
ref="replyForm"
@submit="handleSubmitReply"
@cancel="handleCancelReply"
/>
</div>
</PageWrapper>
</template>
<script lang="ts">
@@ -200,6 +202,7 @@ import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
import AppToolbar from '@/components/AppToolbar.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import PostCard from '@/components/PostCard.vue'
import PostReplyForm from '@/components/PostReplyForm.vue'
import PinIcon from '@icons/Pin.vue'
@@ -227,6 +230,7 @@ export default defineComponent({
NcLoadingIcon,
NcDateTime,
AppToolbar,
PageWrapper,
PostCard,
PostReplyForm,
PinIcon,

View File

@@ -12,7 +12,7 @@
</template>
</AppToolbar>
<div class="preferences-content">
<PageWrapper>
<div class="page-header">
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
@@ -53,7 +53,7 @@
<!-- Actions -->
<div class="form-actions">
<NcButton type="primary" :disabled="saving || !hasChanges" @click="savePreferences">
<NcButton variant="primary" :disabled="saving || !hasChanges" @click="savePreferences">
<template #icon>
<NcLoadingIcon v-if="saving" :size="20" />
<CheckIcon v-else :size="20" />
@@ -71,7 +71,7 @@
<span>{{ strings.saveSuccess }}</span>
</div>
</div>
</div>
</PageWrapper>
</div>
</template>
@@ -82,6 +82,7 @@ import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import AppToolbar from '@/components/AppToolbar.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import CheckIcon from '@icons/Check.vue'
import { ocs } from '@/axios'
@@ -99,6 +100,7 @@ export default defineComponent({
NcLoadingIcon,
NcCheckboxRadioSwitch,
AppToolbar,
PageWrapper,
ArrowLeftIcon,
CheckIcon,
},
@@ -199,25 +201,6 @@ export default defineComponent({
<style scoped lang="scss">
.user-preferences-view {
.preferences-content {
padding: 16px;
max-width: 800px;
margin: 0 auto;
}
.page-header {
margin-bottom: 24px;
h2 {
margin: 0 0 6px 0;
}
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;
}
}
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;
@@ -235,10 +218,18 @@ export default defineComponent({
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
}
.page-header {
margin-bottom: 24px;
h2 {
margin: 0 0 6px 0;
}
}
.preferences-form {
.form-section {
margin-bottom: 32px;
padding: 24px;
@@ -273,9 +264,6 @@ export default defineComponent({
display: flex;
gap: 12px;
align-items: center;
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid var(--color-border);
}
.success-message {

View File

@@ -1,255 +1,257 @@
<template>
<div class="admin-bbcode-list">
<div class="page-header">
<div>
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
<div class="header-actions">
<NcButton @click="showHelp = true">
<template #icon>
<HelpCircleIcon :size="20" />
</template>
{{ strings.help }}
</NcButton>
<NcButton variant="primary" @click="createBBCode">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createBBCode }}
</NcButton>
</div>
</div>
<!-- BBCode Help Dialog -->
<BBCodeHelpDialog v-model:open="showHelp" :show-custom="false" />
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- BBCode list -->
<div v-else class="bbcode-list">
<!-- Enabled BBCodes Section -->
<section class="bbcodes-section">
<div class="section-header">
<h3>{{ strings.enabledTitle }}</h3>
<p class="muted">{{ strings.enabledSubtitle }}</p>
<PageWrapper>
<div class="admin-bbcode-list">
<div class="page-header">
<div>
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
<div class="header-actions">
<NcButton @click="showHelp = true">
<template #icon>
<HelpCircleIcon :size="20" />
</template>
{{ strings.help }}
</NcButton>
<NcButton variant="primary" @click="createBBCode">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createBBCode }}
</NcButton>
</div>
</div>
<div v-if="enabledBBCodes.length > 0" class="bbcodes-table">
<div v-for="bbcode in enabledBBCodes" :key="`bbcode-${bbcode.id}`" class="bbcode-row">
<div class="bbcode-info">
<div class="bbcode-header">
<div class="bbcode-tag">[{{ bbcode.tag }}]</div>
<div v-if="bbcode.parseInner" class="badge badge-info">
{{ strings.parseInner }}
<!-- BBCode Help Dialog -->
<BBCodeHelpDialog v-model:open="showHelp" :show-custom="false" />
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- BBCode list -->
<div v-else class="bbcode-list">
<!-- Enabled BBCodes Section -->
<section class="bbcodes-section">
<div class="section-header">
<h3>{{ strings.enabledTitle }}</h3>
<p class="muted">{{ strings.enabledSubtitle }}</p>
</div>
<div v-if="enabledBBCodes.length > 0" class="bbcodes-table">
<div v-for="bbcode in enabledBBCodes" :key="`bbcode-${bbcode.id}`" class="bbcode-row">
<div class="bbcode-info">
<div class="bbcode-header">
<div class="bbcode-tag">[{{ bbcode.tag }}]</div>
<div v-if="bbcode.parseInner" class="badge badge-info">
{{ strings.parseInner }}
</div>
</div>
<div v-if="bbcode.description" class="bbcode-desc muted">
{{ bbcode.description }}
</div>
<div class="bbcode-replacement">
<span class="label muted">{{ strings.replacement }}:</span>
<code>{{ bbcode.replacement }}</code>
</div>
</div>
<div v-if="bbcode.description" class="bbcode-desc muted">
{{ bbcode.description }}
<div class="bbcode-actions">
<NcButton @click="editBBCode(bbcode)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcButton>
<NcButton @click="toggleEnabled(bbcode)">
<template #icon>
<EyeOffIcon :size="20" />
</template>
{{ strings.disable }}
</NcButton>
<NcButton variant="error" @click="confirmDelete(bbcode)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcButton>
</div>
<div class="bbcode-replacement">
<span class="label muted">{{ strings.replacement }}:</span>
<code>{{ bbcode.replacement }}</code>
</div>
</div>
<div class="bbcode-actions">
<NcButton @click="editBBCode(bbcode)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcButton>
<NcButton @click="toggleEnabled(bbcode)">
<template #icon>
<EyeOffIcon :size="20" />
</template>
{{ strings.disable }}
</NcButton>
<NcButton variant="error" @click="confirmDelete(bbcode)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcButton>
</div>
</div>
</div>
<div v-else class="no-bbcodes muted">
{{ strings.noEnabledBBCodes }}
</div>
</section>
<div v-else class="no-bbcodes muted">
{{ strings.noEnabledBBCodes }}
</div>
</section>
<!-- Disabled BBCodes Section -->
<section v-if="disabledBBCodes.length > 0" class="bbcodes-section">
<div class="section-header">
<h3>{{ strings.disabledTitle }}</h3>
<p class="muted">{{ strings.disabledSubtitle }}</p>
<!-- Disabled BBCodes Section -->
<section v-if="disabledBBCodes.length > 0" class="bbcodes-section">
<div class="section-header">
<h3>{{ strings.disabledTitle }}</h3>
<p class="muted">{{ strings.disabledSubtitle }}</p>
</div>
<div class="bbcodes-table">
<div
v-for="bbcode in disabledBBCodes"
:key="`bbcode-${bbcode.id}`"
class="bbcode-row disabled"
>
<div class="bbcode-info">
<div class="bbcode-header">
<div class="bbcode-tag">[{{ bbcode.tag }}]</div>
<div v-if="bbcode.parseInner" class="badge badge-info">
{{ strings.parseInner }}
</div>
</div>
<div v-if="bbcode.description" class="bbcode-desc muted">
{{ bbcode.description }}
</div>
<div class="bbcode-replacement">
<span class="label muted">{{ strings.replacement }}:</span>
<code>{{ bbcode.replacement }}</code>
</div>
</div>
<div class="bbcode-actions">
<NcButton @click="editBBCode(bbcode)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcButton>
<NcButton variant="primary" @click="toggleEnabled(bbcode)">
<template #icon>
<EyeIcon :size="20" />
</template>
{{ strings.enable }}
</NcButton>
<NcButton variant="error" @click="confirmDelete(bbcode)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcButton>
</div>
</div>
</div>
</section>
</div>
<!-- Delete confirmation dialog -->
<NcDialog
v-if="deleteDialog.show"
:name="strings.deleteDialogTitle"
@close="deleteDialog.show = false"
>
<div class="delete-dialog-content">
<p>{{ strings.deleteConfirmMessage(deleteDialog.bbcode?.tag || '') }}</p>
<p class="muted">{{ strings.deleteWarning }}</p>
</div>
<div class="bbcodes-table">
<div
v-for="bbcode in disabledBBCodes"
:key="`bbcode-${bbcode.id}`"
class="bbcode-row disabled"
<template #actions>
<NcButton @click="deleteDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton variant="error" @click="executeDelete">
{{ strings.deleteBBCode }}
</NcButton>
</template>
</NcDialog>
<!-- BBCode Edit/Create Dialog -->
<NcDialog
v-if="editDialog.show"
:name="editDialog.isEditing ? strings.editBBCodeTitle : strings.createBBCodeTitle"
@close="editDialog.show = false"
>
<div class="bbcode-dialog-content">
<div class="form-group">
<NcTextField
v-model="editDialog.tag"
:label="strings.tag"
:placeholder="strings.tagPlaceholder"
:required="true"
/>
<p class="help-text muted">{{ strings.tagHelp }}</p>
</div>
<div class="form-group">
<NcTextArea
v-model="editDialog.replacement"
:label="strings.replacementLabel"
:placeholder="strings.replacementPlaceholder"
:rows="3"
:required="true"
/>
<p class="help-text muted">{{ strings.replacementHelp }}</p>
</div>
<div class="form-group">
<NcTextArea
v-model="editDialog.example"
:label="strings.exampleLabel"
:placeholder="strings.examplePlaceholder"
:rows="2"
:required="true"
/>
<p class="help-text muted">{{ strings.exampleHelp }}</p>
</div>
<div class="form-group">
<NcTextArea
v-model="editDialog.description"
:label="strings.description"
:placeholder="strings.descriptionPlaceholder"
:rows="2"
/>
</div>
<div class="form-group">
<NcCheckboxRadioSwitch v-model="editDialog.enabled" type="switch">
{{ strings.enabledLabel }}
</NcCheckboxRadioSwitch>
</div>
<div class="form-group">
<NcCheckboxRadioSwitch v-model="editDialog.parseInner" type="switch">
{{ strings.parseInnerLabel }}
</NcCheckboxRadioSwitch>
<p class="help-text muted">{{ strings.parseInnerHelp }}</p>
</div>
</div>
<template #actions>
<NcButton @click="editDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton
variant="primary"
:disabled="
!editDialog.tag.trim() || !editDialog.replacement.trim() || !editDialog.example.trim()
"
@click="saveBBCode"
>
<div class="bbcode-info">
<div class="bbcode-header">
<div class="bbcode-tag">[{{ bbcode.tag }}]</div>
<div v-if="bbcode.parseInner" class="badge badge-info">
{{ strings.parseInner }}
</div>
</div>
<div v-if="bbcode.description" class="bbcode-desc muted">
{{ bbcode.description }}
</div>
<div class="bbcode-replacement">
<span class="label muted">{{ strings.replacement }}:</span>
<code>{{ bbcode.replacement }}</code>
</div>
</div>
<div class="bbcode-actions">
<NcButton @click="editBBCode(bbcode)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcButton>
<NcButton variant="primary" @click="toggleEnabled(bbcode)">
<template #icon>
<EyeIcon :size="20" />
</template>
{{ strings.enable }}
</NcButton>
<NcButton variant="error" @click="confirmDelete(bbcode)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcButton>
</div>
</div>
</div>
</section>
<template v-if="editDialog.submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ editDialog.isEditing ? strings.update : strings.create }}
</NcButton>
</template>
</NcDialog>
</div>
<!-- Delete confirmation dialog -->
<NcDialog
v-if="deleteDialog.show"
:name="strings.deleteDialogTitle"
@close="deleteDialog.show = false"
>
<div class="delete-dialog-content">
<p>{{ strings.deleteConfirmMessage(deleteDialog.bbcode?.tag || '') }}</p>
<p class="muted">{{ strings.deleteWarning }}</p>
</div>
<template #actions>
<NcButton @click="deleteDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton variant="error" @click="executeDelete">
{{ strings.deleteBBCode }}
</NcButton>
</template>
</NcDialog>
<!-- BBCode Edit/Create Dialog -->
<NcDialog
v-if="editDialog.show"
:name="editDialog.isEditing ? strings.editBBCodeTitle : strings.createBBCodeTitle"
@close="editDialog.show = false"
>
<div class="bbcode-dialog-content">
<div class="form-group">
<NcTextField
v-model="editDialog.tag"
:label="strings.tag"
:placeholder="strings.tagPlaceholder"
:required="true"
/>
<p class="help-text muted">{{ strings.tagHelp }}</p>
</div>
<div class="form-group">
<NcTextArea
v-model="editDialog.replacement"
:label="strings.replacementLabel"
:placeholder="strings.replacementPlaceholder"
:rows="3"
:required="true"
/>
<p class="help-text muted">{{ strings.replacementHelp }}</p>
</div>
<div class="form-group">
<NcTextArea
v-model="editDialog.example"
:label="strings.exampleLabel"
:placeholder="strings.examplePlaceholder"
:rows="2"
:required="true"
/>
<p class="help-text muted">{{ strings.exampleHelp }}</p>
</div>
<div class="form-group">
<NcTextArea
v-model="editDialog.description"
:label="strings.description"
:placeholder="strings.descriptionPlaceholder"
:rows="2"
/>
</div>
<div class="form-group">
<NcCheckboxRadioSwitch v-model="editDialog.enabled" type="switch">
{{ strings.enabledLabel }}
</NcCheckboxRadioSwitch>
</div>
<div class="form-group">
<NcCheckboxRadioSwitch v-model="editDialog.parseInner" type="switch">
{{ strings.parseInnerLabel }}
</NcCheckboxRadioSwitch>
<p class="help-text muted">{{ strings.parseInnerHelp }}</p>
</div>
</div>
<template #actions>
<NcButton @click="editDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton
variant="primary"
:disabled="
!editDialog.tag.trim() || !editDialog.replacement.trim() || !editDialog.example.trim()
"
@click="saveBBCode"
>
<template v-if="editDialog.submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ editDialog.isEditing ? strings.update : strings.create }}
</NcButton>
</template>
</NcDialog>
</div>
</PageWrapper>
</template>
<script lang="ts">
@@ -268,6 +270,7 @@ import EyeIcon from '@icons/Eye.vue'
import EyeOffIcon from '@icons/EyeOff.vue'
import HelpCircleIcon from '@icons/HelpCircle.vue'
import BBCodeHelpDialog from '@/components/BBCodeHelpDialog.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
@@ -292,6 +295,7 @@ export default defineComponent({
NcLoadingIcon,
NcTextField,
NcTextArea,
PageWrapper,
PlusIcon,
PencilIcon,
DeleteIcon,
@@ -496,8 +500,6 @@ export default defineComponent({
<style scoped lang="scss">
.admin-bbcode-list {
max-width: 1200px;
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;

View File

@@ -1,192 +1,195 @@
<template>
<div class="admin-category-edit">
<div class="page-header">
<div class="header-actions">
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ strings.back }}
</NcButton>
<PageWrapper>
<div class="admin-category-edit">
<div class="page-header">
<div class="header-actions">
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ strings.back }}
</NcButton>
</div>
<div>
<h2>{{ isEditing ? strings.editCategory : strings.createCategory }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
</div>
<div>
<h2>{{ isEditing ? strings.editCategory : strings.createCategory }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Form -->
<div v-else class="category-form">
<section class="form-section">
<h3>{{ strings.basicInfo }}</h3>
<div class="form-grid">
<div class="form-group">
<label>{{ strings.categoryHeader }} *</label>
<div class="header-select-row">
<NcSelect
v-model="selectedHeader"
:options="headerOptions"
:placeholder="strings.selectHeader"
label="label"
track-by="id"
class="header-select"
/>
<NcButton @click="createNewHeader">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.newHeader }}
</NcButton>
<NcButton v-if="selectedHeader" @click="editHeader">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.editHeader }}
</NcButton>
</div>
</div>
<!-- Form -->
<div v-else class="category-form">
<section class="form-section">
<h3>{{ strings.basicInfo }}</h3>
<div class="form-grid">
<div class="form-group">
<label>{{ strings.categoryHeader }} *</label>
<div class="header-select-row">
<NcSelect
v-model="selectedHeader"
:options="headerOptions"
:placeholder="strings.selectHeader"
label="label"
track-by="id"
class="header-select"
<div class="form-group">
<NcTextField
v-model="formData.name"
:label="strings.name"
:placeholder="strings.namePlaceholder"
:required="true"
/>
</div>
<div class="form-group">
<NcTextField
v-model="formData.slug"
:label="strings.slug"
:placeholder="strings.slugPlaceholder"
:required="true"
/>
<p class="help-text muted">{{ strings.slugHelp }}</p>
</div>
<div class="form-group">
<NcTextArea
v-model="formData.description"
:label="strings.description"
:placeholder="strings.descriptionPlaceholder"
:rows="3"
/>
<NcButton @click="createNewHeader">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.newHeader }}
</NcButton>
<NcButton v-if="selectedHeader" @click="editHeader">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.editHeader }}
</NcButton>
</div>
</div>
</section>
<div class="form-group">
<NcTextField
v-model="formData.name"
:label="strings.name"
:placeholder="strings.namePlaceholder"
:required="true"
/>
<!-- Permissions Section -->
<section class="form-section">
<h3>{{ strings.permissions }}</h3>
<p class="muted">{{ strings.permissionsDescription }}</p>
<div class="form-grid">
<div class="form-group">
<label>{{ strings.viewRoles }}</label>
<NcSelect
v-model="selectedViewRoles"
:options="roleOptions"
:placeholder="strings.selectRoles"
label="label"
track-by="id"
:multiple="true"
:taggable="false"
:close-on-select="false"
/>
<p class="help-text muted">{{ strings.viewRolesHelp }}</p>
</div>
<div class="form-group">
<label>{{ strings.moderateRoles }}</label>
<NcSelect
v-model="selectedModerateRoles"
:options="roleOptions"
:placeholder="strings.selectRoles"
label="label"
track-by="id"
:multiple="true"
:taggable="false"
:close-on-select="false"
/>
<p class="help-text muted">{{ strings.moderateRolesHelp }}</p>
</div>
</div>
</section>
<!-- Actions -->
<div class="form-actions">
<NcButton @click="goBack">{{ strings.cancel }}</NcButton>
<NcButton variant="primary" :disabled="!canSubmit || submitting" @click="submitForm">
<template v-if="submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ isEditing ? strings.update : strings.create }}
</NcButton>
</div>
</div>
<!-- Header Edit/Create Dialog -->
<NcDialog
v-if="headerDialog.show"
:name="headerDialog.isEditing ? strings.editHeaderTitle : strings.createHeaderTitle"
@close="headerDialog.show = false"
>
<div class="header-dialog-content">
<div class="form-group">
<NcTextField
v-model="formData.slug"
:label="strings.slug"
:placeholder="strings.slugPlaceholder"
v-model="headerDialog.name"
:label="strings.headerName"
:placeholder="strings.headerNamePlaceholder"
:required="true"
/>
<p class="help-text muted">{{ strings.slugHelp }}</p>
</div>
<div class="form-group">
<NcTextArea
v-model="formData.description"
:label="strings.description"
:placeholder="strings.descriptionPlaceholder"
:rows="3"
v-model="headerDialog.description"
:label="strings.headerDescription"
:placeholder="strings.headerDescriptionPlaceholder"
:rows="2"
/>
</div>
</div>
</section>
<!-- Permissions Section -->
<section class="form-section">
<h3>{{ strings.permissions }}</h3>
<p class="muted">{{ strings.permissionsDescription }}</p>
<div class="form-grid">
<div class="form-group">
<label>{{ strings.viewRoles }}</label>
<NcSelect
v-model="selectedViewRoles"
:options="roleOptions"
:placeholder="strings.selectRoles"
label="label"
track-by="id"
:multiple="true"
:taggable="false"
:close-on-select="false"
/>
<p class="help-text muted">{{ strings.viewRolesHelp }}</p>
</div>
<div class="form-group">
<label>{{ strings.moderateRoles }}</label>
<NcSelect
v-model="selectedModerateRoles"
:options="roleOptions"
:placeholder="strings.selectRoles"
label="label"
track-by="id"
:multiple="true"
:taggable="false"
:close-on-select="false"
/>
<p class="help-text muted">{{ strings.moderateRolesHelp }}</p>
</div>
</div>
</section>
<!-- Actions -->
<div class="form-actions">
<NcButton @click="goBack">{{ strings.cancel }}</NcButton>
<NcButton variant="primary" :disabled="!canSubmit || submitting" @click="submitForm">
<template v-if="submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ isEditing ? strings.update : strings.create }}
</NcButton>
</div>
<template #actions>
<NcButton @click="headerDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton variant="primary" :disabled="!headerDialog.name.trim()" @click="saveHeader">
<template v-if="headerDialog.submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ headerDialog.isEditing ? strings.update : strings.create }}
</NcButton>
</template>
</NcDialog>
</div>
<!-- Header Edit/Create Dialog -->
<NcDialog
v-if="headerDialog.show"
:name="headerDialog.isEditing ? strings.editHeaderTitle : strings.createHeaderTitle"
@close="headerDialog.show = false"
>
<div class="header-dialog-content">
<div class="form-group">
<NcTextField
v-model="headerDialog.name"
:label="strings.headerName"
:placeholder="strings.headerNamePlaceholder"
:required="true"
/>
</div>
<div class="form-group">
<NcTextArea
v-model="headerDialog.description"
:label="strings.headerDescription"
:placeholder="strings.headerDescriptionPlaceholder"
:rows="2"
/>
</div>
</div>
<template #actions>
<NcButton @click="headerDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton variant="primary" :disabled="!headerDialog.name.trim()" @click="saveHeader">
<template v-if="headerDialog.submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ headerDialog.isEditing ? strings.update : strings.create }}
</NcButton>
</template>
</NcDialog>
</div>
</PageWrapper>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import PageWrapper from '@/components/PageWrapper.vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
@@ -211,6 +214,7 @@ export default defineComponent({
NcSelect,
NcTextField,
NcTextArea,
PageWrapper,
ArrowLeftIcon,
PlusIcon,
PencilIcon,
@@ -559,8 +563,6 @@ export default defineComponent({
<style scoped lang="scss">
.admin-category-edit {
max-width: 800px;
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;

View File

@@ -1,112 +1,56 @@
<template>
<div class="admin-category-list">
<div class="page-header">
<div>
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
<PageWrapper>
<div class="admin-category-list">
<div class="page-header">
<div>
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
<div class="header-actions">
<NcButton @click="createHeader">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createHeader }}
</NcButton>
<NcButton variant="primary" @click="createCategory">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createCategory }}
</NcButton>
</div>
</div>
<div class="header-actions">
<NcButton @click="createHeader">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createHeader }}
</NcButton>
<NcButton variant="primary" @click="createCategory">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createCategory }}
</NcButton>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Category list -->
<div v-else class="category-list">
<!-- Categories by Header -->
<section class="categories-section">
<template v-for="(header, headerIndex) in categoryHeaders" :key="header.id">
<div class="header-row">
<div class="header-sort-buttons">
<NcButton
v-if="headerIndex > 0"
variant="tertiary"
@click="moveHeaderUp(headerIndex)"
:aria-label="strings.moveUp"
:title="strings.moveUp"
>
<template #icon>
<ChevronUpIcon :size="20" />
</template>
</NcButton>
<NcButton
v-if="headerIndex < categoryHeaders.length - 1"
variant="tertiary"
@click="moveHeaderDown(headerIndex)"
:aria-label="strings.moveDown"
:title="strings.moveDown"
>
<template #icon>
<ChevronDownIcon :size="20" />
</template>
</NcButton>
</div>
<div class="header-info">
<h3>{{ header.name }}</h3>
<span v-if="header.description" class="muted">{{ header.description }}</span>
<span class="muted category-count"
>{{ header.categories?.length || 0 }} {{ strings.categoriesCount }}</span
>
</div>
<div class="header-actions">
<NcButton @click="editHeaderById(header.id)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcButton>
<NcButton
variant="error"
:disabled="categoryHeaders.length <= 1"
@click="confirmDeleteHeader(header)"
>
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcButton>
</div>
</div>
<div v-if="header.categories && header.categories.length > 0" class="categories-table">
<div
v-for="(category, index) in header.categories"
:key="category.id"
class="category-row"
>
<div class="category-sort-buttons">
<!-- Category list -->
<div v-else class="category-list">
<!-- Categories by Header -->
<section class="categories-section">
<template v-for="(header, headerIndex) in categoryHeaders" :key="header.id">
<div class="header-row">
<div class="header-sort-buttons">
<NcButton
v-if="index > 0"
v-if="headerIndex > 0"
variant="tertiary"
@click="moveCategoryUp(header.id, index)"
@click="moveHeaderUp(headerIndex)"
:aria-label="strings.moveUp"
:title="strings.moveUp"
>
@@ -115,9 +59,9 @@
</template>
</NcButton>
<NcButton
v-if="index < header.categories.length - 1"
v-if="headerIndex < categoryHeaders.length - 1"
variant="tertiary"
@click="moveCategoryDown(header.id, index)"
@click="moveHeaderDown(headerIndex)"
:aria-label="strings.moveDown"
:title="strings.moveDown"
>
@@ -126,27 +70,25 @@
</template>
</NcButton>
</div>
<div class="category-info">
<div class="category-name">{{ category.name }}</div>
<div v-if="category.description" class="category-desc muted">
{{ category.description }}
</div>
<div class="category-meta muted">
<span>Slug: {{ category.slug }}</span>
<span></span>
<span>Threads: {{ category.threadCount || 0 }}</span>
<span></span>
<span>Posts: {{ category.postCount || 0 }}</span>
</div>
<div class="header-info">
<h3>{{ header.name }}</h3>
<span v-if="header.description" class="muted">{{ header.description }}</span>
<span class="muted category-count"
>{{ header.categories?.length || 0 }} {{ strings.categoriesCount }}</span
>
</div>
<div class="category-actions">
<NcButton @click="editCategory(category.id)">
<div class="header-actions">
<NcButton @click="editHeaderById(header.id)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcButton>
<NcButton variant="error" @click="confirmDelete(category)">
<NcButton
variant="error"
:disabled="categoryHeaders.length <= 1"
@click="confirmDeleteHeader(header)"
>
<template #icon>
<DeleteIcon :size="20" />
</template>
@@ -154,201 +96,262 @@
</NcButton>
</div>
</div>
</div>
<div v-else class="no-categories muted">
{{ strings.noCategories }}
</div>
</template>
</section>
</div>
<!-- Delete confirmation dialog -->
<NcDialog
v-if="deleteDialog.show"
:name="strings.deleteDialogTitle"
@close="deleteDialog.show = false"
>
<div class="delete-dialog-content">
<p>{{ strings.deleteConfirmMessage(deleteDialog.category?.name || '') }}</p>
<div v-if="deleteDialog.threadCount > 0" class="thread-warning">
<InformationIcon :size="20" />
<span>{{ strings.threadWarning(deleteDialog.threadCount) }}</span>
</div>
<div v-if="deleteDialog.threadCount > 0" class="migration-options">
<h4>{{ strings.whatToDoWithThreads }}</h4>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteDialog.action"
value="migrate"
type="radio"
name="delete-action"
>
{{ strings.migrateThreads }}
</NcCheckboxRadioSwitch>
<div v-if="deleteDialog.action === 'migrate'" class="category-select">
<label>{{ strings.selectTargetCategory }}</label>
<NcSelect
v-model="selectedTargetCategory"
:options="targetCategoryOptions"
:placeholder="strings.selectCategory"
label="label"
track-by="id"
/>
<div v-if="header.categories && header.categories.length > 0" class="categories-table">
<div
v-for="(category, index) in header.categories"
:key="category.id"
class="category-row"
>
<div class="category-sort-buttons">
<NcButton
v-if="index > 0"
variant="tertiary"
@click="moveCategoryUp(header.id, index)"
:aria-label="strings.moveUp"
:title="strings.moveUp"
>
<template #icon>
<ChevronUpIcon :size="20" />
</template>
</NcButton>
<NcButton
v-if="index < header.categories.length - 1"
variant="tertiary"
@click="moveCategoryDown(header.id, index)"
:aria-label="strings.moveDown"
:title="strings.moveDown"
>
<template #icon>
<ChevronDownIcon :size="20" />
</template>
</NcButton>
</div>
<div class="category-info">
<div class="category-name">{{ category.name }}</div>
<div v-if="category.description" class="category-desc muted">
{{ category.description }}
</div>
<div class="category-meta muted">
<span>Slug: {{ category.slug }}</span>
<span></span>
<span>Threads: {{ category.threadCount || 0 }}</span>
<span></span>
<span>Posts: {{ category.postCount || 0 }}</span>
</div>
</div>
<div class="category-actions">
<NcButton @click="editCategory(category.id)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcButton>
<NcButton variant="error" @click="confirmDelete(category)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcButton>
</div>
</div>
</div>
<div v-else class="no-categories muted">
{{ strings.noCategories }}
</div>
</div>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteDialog.action"
value="delete"
type="radio"
name="delete-action"
>
{{ strings.softDeleteThreads }}
</NcCheckboxRadioSwitch>
<p class="help-text muted">{{ strings.softDeleteHelp }}</p>
</div>
</div>
</div>
<template #actions>
<NcButton @click="deleteDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton
variant="error"
:disabled="deleteDialog.action === 'migrate' && !selectedTargetCategory"
@click="executeDelete"
>
{{ strings.deleteCategory }}
</NcButton>
</template>
</NcDialog>
<!-- Header Edit/Create Dialog -->
<NcDialog
v-if="headerDialog.show"
:name="headerDialog.isEditing ? strings.editHeaderTitle : strings.createHeaderTitle"
@close="headerDialog.show = false"
>
<div class="header-dialog-content">
<div class="form-group">
<NcTextField
v-model="headerDialog.name"
:label="strings.headerName"
:placeholder="strings.headerNamePlaceholder"
:required="true"
/>
</div>
<div class="form-group">
<NcTextArea
v-model="headerDialog.description"
:label="strings.headerDescription"
:placeholder="strings.headerDescriptionPlaceholder"
:rows="2"
/>
</div>
<div class="form-group">
<NcTextField
v-model.number="headerDialog.sortOrder"
:label="strings.headerSortOrder"
:placeholder="strings.sortOrderPlaceholder"
type="number"
/>
<p class="help-text muted">{{ strings.sortOrderHelp }}</p>
</div>
</div>
<template #actions>
<NcButton @click="headerDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton variant="primary" :disabled="!headerDialog.name.trim()" @click="saveHeader">
<template v-if="headerDialog.submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ headerDialog.isEditing ? strings.update : strings.create }}
</NcButton>
</template>
</NcDialog>
<!-- Header Delete Confirmation Dialog -->
<NcDialog
v-if="deleteHeaderDialog.show"
:name="strings.deleteHeaderTitle"
@close="deleteHeaderDialog.show = false"
>
<div class="delete-dialog-content">
<p>{{ strings.deleteHeaderMessage(deleteHeaderDialog.header?.name || '') }}</p>
<div v-if="deleteHeaderDialog.categoryCount > 0" class="thread-warning">
<InformationIcon :size="20" />
<span>{{ strings.headerCategoryWarning(deleteHeaderDialog.categoryCount) }}</span>
</div>
<div v-if="deleteHeaderDialog.categoryCount > 0" class="migration-options">
<h4>{{ strings.whatToDoWithCategories }}</h4>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteHeaderDialog.action"
value="migrate"
type="radio"
name="delete-header-action"
>
{{ strings.migrateCategories }}
</NcCheckboxRadioSwitch>
<div v-if="deleteHeaderDialog.action === 'migrate'" class="category-select">
<label>{{ strings.selectTargetHeader }}</label>
<NcSelect
v-model="selectedTargetHeader"
:options="targetHeaderOptions"
:placeholder="strings.selectHeader"
label="label"
track-by="id"
/>
</div>
</div>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteHeaderDialog.action"
value="delete"
type="radio"
name="delete-header-action"
>
{{ strings.deleteCategories }}
</NcCheckboxRadioSwitch>
<p class="help-text muted">{{ strings.deleteCategoriesHelp }}</p>
</div>
</div>
</section>
</div>
<template #actions>
<NcButton @click="deleteHeaderDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton
variant="error"
:disabled="deleteHeaderDialog.action === 'migrate' && !selectedTargetHeader"
@click="executeDeleteHeader"
>
{{ strings.deleteHeader }}
</NcButton>
</template>
</NcDialog>
</div>
<!-- Delete confirmation dialog -->
<NcDialog
v-if="deleteDialog.show"
:name="strings.deleteDialogTitle"
@close="deleteDialog.show = false"
>
<div class="delete-dialog-content">
<p>{{ strings.deleteConfirmMessage(deleteDialog.category?.name || '') }}</p>
<div v-if="deleteDialog.threadCount > 0" class="thread-warning">
<InformationIcon :size="20" />
<span>{{ strings.threadWarning(deleteDialog.threadCount) }}</span>
</div>
<div v-if="deleteDialog.threadCount > 0" class="migration-options">
<h4>{{ strings.whatToDoWithThreads }}</h4>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteDialog.action"
value="migrate"
type="radio"
name="delete-action"
>
{{ strings.migrateThreads }}
</NcCheckboxRadioSwitch>
<div v-if="deleteDialog.action === 'migrate'" class="category-select">
<label>{{ strings.selectTargetCategory }}</label>
<NcSelect
v-model="selectedTargetCategory"
:options="targetCategoryOptions"
:placeholder="strings.selectCategory"
label="label"
track-by="id"
/>
</div>
</div>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteDialog.action"
value="delete"
type="radio"
name="delete-action"
>
{{ strings.softDeleteThreads }}
</NcCheckboxRadioSwitch>
<p class="help-text muted">{{ strings.softDeleteHelp }}</p>
</div>
</div>
</div>
<template #actions>
<NcButton @click="deleteDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton
variant="error"
:disabled="deleteDialog.action === 'migrate' && !selectedTargetCategory"
@click="executeDelete"
>
{{ strings.deleteCategory }}
</NcButton>
</template>
</NcDialog>
<!-- Header Edit/Create Dialog -->
<NcDialog
v-if="headerDialog.show"
:name="headerDialog.isEditing ? strings.editHeaderTitle : strings.createHeaderTitle"
@close="headerDialog.show = false"
>
<div class="header-dialog-content">
<div class="form-group">
<NcTextField
v-model="headerDialog.name"
:label="strings.headerName"
:placeholder="strings.headerNamePlaceholder"
:required="true"
/>
</div>
<div class="form-group">
<NcTextArea
v-model="headerDialog.description"
:label="strings.headerDescription"
:placeholder="strings.headerDescriptionPlaceholder"
:rows="2"
/>
</div>
<div class="form-group">
<NcTextField
v-model.number="headerDialog.sortOrder"
:label="strings.headerSortOrder"
:placeholder="strings.sortOrderPlaceholder"
type="number"
/>
<p class="help-text muted">{{ strings.sortOrderHelp }}</p>
</div>
</div>
<template #actions>
<NcButton @click="headerDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton variant="primary" :disabled="!headerDialog.name.trim()" @click="saveHeader">
<template v-if="headerDialog.submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ headerDialog.isEditing ? strings.update : strings.create }}
</NcButton>
</template>
</NcDialog>
<!-- Header Delete Confirmation Dialog -->
<NcDialog
v-if="deleteHeaderDialog.show"
:name="strings.deleteHeaderTitle"
@close="deleteHeaderDialog.show = false"
>
<div class="delete-dialog-content">
<p>{{ strings.deleteHeaderMessage(deleteHeaderDialog.header?.name || '') }}</p>
<div v-if="deleteHeaderDialog.categoryCount > 0" class="thread-warning">
<InformationIcon :size="20" />
<span>{{ strings.headerCategoryWarning(deleteHeaderDialog.categoryCount) }}</span>
</div>
<div v-if="deleteHeaderDialog.categoryCount > 0" class="migration-options">
<h4>{{ strings.whatToDoWithCategories }}</h4>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteHeaderDialog.action"
value="migrate"
type="radio"
name="delete-header-action"
>
{{ strings.migrateCategories }}
</NcCheckboxRadioSwitch>
<div v-if="deleteHeaderDialog.action === 'migrate'" class="category-select">
<label>{{ strings.selectTargetHeader }}</label>
<NcSelect
v-model="selectedTargetHeader"
:options="targetHeaderOptions"
:placeholder="strings.selectHeader"
label="label"
track-by="id"
/>
</div>
</div>
<div class="radio-group">
<NcCheckboxRadioSwitch
v-model="deleteHeaderDialog.action"
value="delete"
type="radio"
name="delete-header-action"
>
{{ strings.deleteCategories }}
</NcCheckboxRadioSwitch>
<p class="help-text muted">{{ strings.deleteCategoriesHelp }}</p>
</div>
</div>
</div>
<template #actions>
<NcButton @click="deleteHeaderDialog.show = false">
{{ strings.cancel }}
</NcButton>
<NcButton
variant="error"
:disabled="deleteHeaderDialog.action === 'migrate' && !selectedTargetHeader"
@click="executeDeleteHeader"
>
{{ strings.deleteHeader }}
</NcButton>
</template>
</NcDialog>
</div>
</PageWrapper>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import PageWrapper from '@/components/PageWrapper.vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
import NcDialog from '@nextcloud/vue/components/NcDialog'
@@ -378,6 +381,7 @@ export default defineComponent({
NcSelect,
NcTextField,
NcTextArea,
PageWrapper,
PlusIcon,
PencilIcon,
DeleteIcon,
@@ -753,8 +757,6 @@ export default defineComponent({
<style scoped lang="scss">
.admin-category-list {
max-width: 1200px;
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;

View File

@@ -1,139 +1,141 @@
<template>
<div class="admin-dashboard">
<div class="page-header">
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
<PageWrapper>
<div class="admin-dashboard">
<div class="page-header">
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Dashboard content -->
<div v-else-if="stats" class="dashboard-content">
<!-- Totals section -->
<section class="stats-section">
<h3>{{ strings.totals }}</h3>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<AccountMultipleIcon :size="32" />
<!-- Dashboard content -->
<div v-else-if="stats" class="dashboard-content">
<!-- Totals section -->
<section class="stats-section">
<h3>{{ strings.totals }}</h3>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<AccountMultipleIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.totals.users }}</div>
<div class="stat-label">{{ strings.totalUsers }}</div>
</div>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.totals.users }}</div>
<div class="stat-label">{{ strings.totalUsers }}</div>
<div class="stat-card">
<div class="stat-icon">
<ForumIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.totals.threads }}</div>
<div class="stat-label">{{ strings.totalThreads }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<MessageTextIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.totals.posts }}</div>
<div class="stat-label">{{ strings.totalPosts }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<FolderIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.totals.categories }}</div>
<div class="stat-label">{{ strings.totalCategories }}</div>
</div>
</div>
</div>
</section>
<div class="stat-card">
<div class="stat-icon">
<ForumIcon :size="32" />
<!-- Recent activity section -->
<section class="stats-section mt-24">
<h3>{{ strings.recentActivity }}</h3>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<AccountPlusIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.recent.users }}</div>
<div class="stat-label">{{ strings.newUsers }}</div>
</div>
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.totals.threads }}</div>
<div class="stat-label">{{ strings.totalThreads }}</div>
<div class="stat-card">
<div class="stat-icon">
<ForumIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.recent.threads }}</div>
<div class="stat-label">{{ strings.newThreads }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<MessageTextIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.recent.posts }}</div>
<div class="stat-label">{{ strings.newPosts }}</div>
</div>
</div>
</div>
</section>
<div class="stat-card">
<div class="stat-icon">
<MessageTextIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.totals.posts }}</div>
<div class="stat-label">{{ strings.totalPosts }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<FolderIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.totals.categories }}</div>
<div class="stat-label">{{ strings.totalCategories }}</div>
</div>
</div>
</div>
</section>
<!-- Recent activity section -->
<section class="stats-section mt-24">
<h3>{{ strings.recentActivity }}</h3>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon">
<AccountPlusIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.recent.users }}</div>
<div class="stat-label">{{ strings.newUsers }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<ForumIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.recent.threads }}</div>
<div class="stat-label">{{ strings.newThreads }}</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<MessageTextIcon :size="32" />
</div>
<div class="stat-info">
<div class="stat-value">{{ stats.recent.posts }}</div>
<div class="stat-label">{{ strings.newPosts }}</div>
</div>
</div>
</div>
</section>
<!-- Top contributors section -->
<section class="stats-section mt-24">
<h3>{{ strings.topContributors }}</h3>
<div v-if="stats.topContributors.length > 0" class="contributors-list">
<div
v-for="(contributor, index) in stats.topContributors"
:key="contributor.userId"
class="contributor-item"
>
<div class="contributor-rank">{{ index + 1 }}</div>
<UserInfo
:user-id="contributor.userId"
:display-name="contributor.userId"
:avatar-size="40"
<!-- Top contributors section -->
<section class="stats-section mt-24">
<h3>{{ strings.topContributors }}</h3>
<div v-if="stats.topContributors.length > 0" class="contributors-list">
<div
v-for="(contributor, index) in stats.topContributors"
:key="contributor.userId"
class="contributor-item"
>
<template #meta>
<div class="contributor-stats muted">
{{ strings.postsCount(contributor.postCount) }}
</div>
</template>
</UserInfo>
<div class="contributor-rank">{{ index + 1 }}</div>
<UserInfo
:user-id="contributor.userId"
:display-name="contributor.userId"
:avatar-size="40"
>
<template #meta>
<div class="contributor-stats muted">
{{ strings.postsCount(contributor.postCount) }}
</div>
</template>
</UserInfo>
</div>
</div>
</div>
<div v-else class="muted">{{ strings.noContributors }}</div>
</section>
<div v-else class="muted">{{ strings.noContributors }}</div>
</section>
</div>
</div>
</div>
</PageWrapper>
</template>
<script lang="ts">
@@ -142,6 +144,7 @@ import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import UserInfo from '@/components/UserInfo.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import AccountMultipleIcon from '@icons/AccountMultiple.vue'
import AccountPlusIcon from '@icons/AccountPlus.vue'
import ForumIcon from '@icons/Forum.vue'
@@ -175,6 +178,7 @@ export default defineComponent({
NcEmptyContent,
NcLoadingIcon,
UserInfo,
PageWrapper,
AccountMultipleIcon,
AccountPlusIcon,
ForumIcon,

View File

@@ -1,78 +1,80 @@
<template>
<div class="admin-general-settings">
<div class="page-header">
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
<PageWrapper>
<div class="page-header">
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="loadSettings">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="loadSettings">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Settings form -->
<div v-else class="settings-form">
<div class="form-section">
<h3>{{ strings.appearanceTitle }}</h3>
<p class="muted">{{ strings.appearanceDesc }}</p>
<!-- Settings form -->
<div v-else class="settings-form">
<div class="form-section">
<h3>{{ strings.appearanceTitle }}</h3>
<p class="muted">{{ strings.appearanceDesc }}</p>
<div class="form-group">
<label for="forum-title">{{ strings.forumTitle }}</label>
<NcTextField
id="forum-title"
v-model.trim="formData.title"
:placeholder="strings.forumTitlePlaceholder"
:maxlength="100"
/>
<p class="hint">{{ strings.forumTitleHint }}</p>
<div class="form-group">
<label for="forum-title">{{ strings.forumTitle }}</label>
<NcTextField
id="forum-title"
v-model.trim="formData.title"
:placeholder="strings.forumTitlePlaceholder"
:maxlength="100"
/>
<p class="hint">{{ strings.forumTitleHint }}</p>
</div>
<div class="form-group">
<label for="forum-subtitle">{{ strings.forumSubtitle }}</label>
<NcTextArea
id="forum-subtitle"
v-model.trim="formData.subtitle"
:placeholder="strings.forumSubtitlePlaceholder"
:rows="3"
:maxlength="500"
/>
<p class="hint">{{ strings.forumSubtitleHint }}</p>
</div>
</div>
<div class="form-group">
<label for="forum-subtitle">{{ strings.forumSubtitle }}</label>
<NcTextArea
id="forum-subtitle"
v-model.trim="formData.subtitle"
:placeholder="strings.forumSubtitlePlaceholder"
:rows="3"
:maxlength="500"
/>
<p class="hint">{{ strings.forumSubtitleHint }}</p>
<!-- Actions -->
<div class="form-actions">
<NcButton :disabled="saving || !hasChanges" @click="saveSettings">
<template #icon>
<NcLoadingIcon v-if="saving" :size="20" />
<CheckIcon v-else :size="20" />
</template>
{{ strings.save }}
</NcButton>
<NcButton :disabled="saving || !hasChanges" @click="resetForm">
{{ strings.cancel }}
</NcButton>
</div>
<!-- Success message -->
<div v-if="saveSuccess" class="success-message">
<CheckIcon :size="20" />
<span>{{ strings.saveSuccess }}</span>
</div>
</div>
<!-- Actions -->
<div class="form-actions">
<NcButton :disabled="saving || !hasChanges" @click="saveSettings">
<template #icon>
<NcLoadingIcon v-if="saving" :size="20" />
<CheckIcon v-else :size="20" />
</template>
{{ strings.save }}
</NcButton>
<NcButton :disabled="saving || !hasChanges" @click="resetForm">
{{ strings.cancel }}
</NcButton>
</div>
<!-- Success message -->
<div v-if="saveSuccess" class="success-message">
<CheckIcon :size="20" />
<span>{{ strings.saveSuccess }}</span>
</div>
</div>
</PageWrapper>
</div>
</template>
@@ -83,6 +85,7 @@ import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcTextArea from '@nextcloud/vue/components/NcTextArea'
import PageWrapper from '@/components/PageWrapper.vue'
import CheckIcon from '@icons/Check.vue'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
@@ -100,6 +103,7 @@ export default defineComponent({
NcLoadingIcon,
NcTextField,
NcTextArea,
PageWrapper,
CheckIcon,
},
data() {
@@ -225,7 +229,6 @@ export default defineComponent({
}
.settings-form {
max-width: 800px;
.form-section {
margin-bottom: 32px;

View File

@@ -1,163 +1,165 @@
<template>
<div class="admin-role-edit">
<div class="page-header">
<div class="header-actions">
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ strings.back }}
</NcButton>
</div>
<div>
<h2>{{ isEditing ? strings.editRole : strings.createRole }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Form -->
<div v-else class="role-form">
<!-- Basic Info Section -->
<section class="form-section">
<h3>{{ strings.basicInfo }}</h3>
<div class="form-grid">
<div class="form-group">
<NcTextField
v-model="formData.name"
:label="strings.name"
:placeholder="strings.namePlaceholder"
:disabled="isSystemRole"
:required="true"
/>
<p v-if="isSystemRole" class="help-text muted">
{{ strings.systemRoleNameWarning }}
</p>
</div>
<div class="form-group">
<NcTextArea
v-model="formData.description"
:label="strings.description"
:placeholder="strings.descriptionPlaceholder"
:rows="3"
/>
</div>
<PageWrapper>
<div class="admin-role-edit">
<div class="page-header">
<div class="header-actions">
<NcButton @click="goBack">
<template #icon>
<ArrowLeftIcon :size="20" />
</template>
{{ strings.back }}
</NcButton>
</div>
</section>
<!-- Role Permissions Section -->
<section class="form-section">
<h3>{{ strings.rolePermissions }}</h3>
<p class="muted">{{ strings.rolePermissionsDesc }}</p>
<div class="permissions-checkboxes">
<div class="checkbox-group">
<NcCheckboxRadioSwitch v-model="formData.canAccessAdminTools">
<strong>{{ strings.canAccessAdminTools }}</strong>
<span class="checkbox-desc muted">{{ strings.canAccessAdminToolsDesc }}</span>
</NcCheckboxRadioSwitch>
</div>
<div class="checkbox-group">
<NcCheckboxRadioSwitch v-model="formData.canEditRoles">
<strong>{{ strings.canEditRoles }}</strong>
<span class="checkbox-desc muted">{{ strings.canEditRolesDesc }}</span>
</NcCheckboxRadioSwitch>
</div>
<div class="checkbox-group">
<NcCheckboxRadioSwitch v-model="formData.canEditCategories">
<strong>{{ strings.canEditCategories }}</strong>
<span class="checkbox-desc muted">{{ strings.canEditCategoriesDesc }}</span>
</NcCheckboxRadioSwitch>
</div>
<div>
<h2>{{ isEditing ? strings.editRole : strings.createRole }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
</section>
</div>
<!-- Category Permissions Section -->
<section class="form-section">
<h3>{{ strings.categoryPermissions }}</h3>
<p v-if="isAdmin" class="info-message">
<InformationIcon :size="20" />
{{ strings.adminFullAccess }}
</p>
<p v-else class="muted">{{ strings.categoryPermissionsDesc }}</p>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<div v-if="categoryHeaders.length > 0" class="permissions-table">
<div class="table-header">
<div class="col-category">{{ strings.category }}</div>
<div class="col-permission">{{ strings.canView }}</div>
<div class="col-permission">{{ strings.canModerate }}</div>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<template v-for="header in categoryHeaders" :key="`header-${header.id}`">
<!-- Header row -->
<div class="table-header-row">
<div class="header-name">{{ header.name }}</div>
<!-- Form -->
<div v-else class="role-form">
<!-- Basic Info Section -->
<section class="form-section">
<h3>{{ strings.basicInfo }}</h3>
<div class="form-grid">
<div class="form-group">
<NcTextField
v-model="formData.name"
:label="strings.name"
:placeholder="strings.namePlaceholder"
:disabled="isSystemRole"
:required="true"
/>
<p v-if="isSystemRole" class="help-text muted">
{{ strings.systemRoleNameWarning }}
</p>
</div>
<!-- Category rows under this header -->
<div v-for="category in header.categories" :key="category.id" class="table-row">
<div class="col-category">
<span class="category-name">{{ category.name }}</span>
<span v-if="category.description" class="category-desc muted">
{{ category.description }}
</span>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
v-model="ensurePermission(category.id).canView"
:disabled="isAdmin"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
v-model="ensurePermission(category.id).canModerate"
:disabled="isAdmin"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<div class="form-group">
<NcTextArea
v-model="formData.description"
:label="strings.description"
:placeholder="strings.descriptionPlaceholder"
:rows="3"
/>
</div>
</template>
</div>
<div v-else class="muted">{{ strings.noCategories }}</div>
</section>
</div>
</section>
<!-- Actions -->
<div class="form-actions">
<NcButton @click="goBack">{{ strings.cancel }}</NcButton>
<NcButton variant="primary" :disabled="!canSubmit || submitting" @click="submitForm">
<template v-if="submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ isEditing ? strings.update : strings.create }}
</NcButton>
<!-- Role Permissions Section -->
<section class="form-section">
<h3>{{ strings.rolePermissions }}</h3>
<p class="muted">{{ strings.rolePermissionsDesc }}</p>
<div class="permissions-checkboxes">
<div class="checkbox-group">
<NcCheckboxRadioSwitch v-model="formData.canAccessAdminTools">
<strong>{{ strings.canAccessAdminTools }}</strong>
<span class="checkbox-desc muted">{{ strings.canAccessAdminToolsDesc }}</span>
</NcCheckboxRadioSwitch>
</div>
<div class="checkbox-group">
<NcCheckboxRadioSwitch v-model="formData.canEditRoles">
<strong>{{ strings.canEditRoles }}</strong>
<span class="checkbox-desc muted">{{ strings.canEditRolesDesc }}</span>
</NcCheckboxRadioSwitch>
</div>
<div class="checkbox-group">
<NcCheckboxRadioSwitch v-model="formData.canEditCategories">
<strong>{{ strings.canEditCategories }}</strong>
<span class="checkbox-desc muted">{{ strings.canEditCategoriesDesc }}</span>
</NcCheckboxRadioSwitch>
</div>
</div>
</section>
<!-- Category Permissions Section -->
<section class="form-section">
<h3>{{ strings.categoryPermissions }}</h3>
<p v-if="isAdmin" class="info-message">
<InformationIcon :size="20" />
{{ strings.adminFullAccess }}
</p>
<p v-else class="muted">{{ strings.categoryPermissionsDesc }}</p>
<div v-if="categoryHeaders.length > 0" class="permissions-table">
<div class="table-header">
<div class="col-category">{{ strings.category }}</div>
<div class="col-permission">{{ strings.canView }}</div>
<div class="col-permission">{{ strings.canModerate }}</div>
</div>
<template v-for="header in categoryHeaders" :key="`header-${header.id}`">
<!-- Header row -->
<div class="table-header-row">
<div class="header-name">{{ header.name }}</div>
</div>
<!-- Category rows under this header -->
<div v-for="category in header.categories" :key="category.id" class="table-row">
<div class="col-category">
<span class="category-name">{{ category.name }}</span>
<span v-if="category.description" class="category-desc muted">
{{ category.description }}
</span>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
v-model="ensurePermission(category.id).canView"
:disabled="isAdmin"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
<div class="col-permission">
<NcCheckboxRadioSwitch
v-model="ensurePermission(category.id).canModerate"
:disabled="isAdmin"
>
{{ strings.allow }}
</NcCheckboxRadioSwitch>
</div>
</div>
</template>
</div>
<div v-else class="muted">{{ strings.noCategories }}</div>
</section>
<!-- Actions -->
<div class="form-actions">
<NcButton @click="goBack">{{ strings.cancel }}</NcButton>
<NcButton variant="primary" :disabled="!canSubmit || submitting" @click="submitForm">
<template v-if="submitting" #icon>
<NcLoadingIcon :size="20" />
</template>
{{ isEditing ? strings.update : strings.create }}
</NcButton>
</div>
</div>
</div>
</div>
</PageWrapper>
</template>
<script lang="ts">
@@ -170,6 +172,7 @@ import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcTextArea from '@nextcloud/vue/components/NcTextArea'
import ArrowLeftIcon from '@icons/ArrowLeft.vue'
import InformationIcon from '@icons/Information.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
import type { Role, CategoryHeader } from '@/types'
@@ -190,6 +193,7 @@ export default defineComponent({
NcTextArea,
ArrowLeftIcon,
InformationIcon,
PageWrapper,
},
data() {
return {
@@ -429,8 +433,6 @@ export default defineComponent({
<style scoped lang="scss">
.admin-role-edit {
max-width: 1200px;
.muted {
color: var(--color-text-maxcontrast);
opacity: 0.7;

View File

@@ -1,104 +1,106 @@
<template>
<div class="admin-role-list">
<div class="page-header">
<div class="header-content">
<div>
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
<PageWrapper>
<div class="admin-role-list">
<div class="page-header">
<div class="header-content">
<div>
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
<NcButton @click="createRole" variant="primary">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createRole }}
</NcButton>
</div>
<NcButton @click="createRole" variant="primary">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createRole }}
</NcButton>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Role list -->
<div v-else-if="roles.length > 0" class="roles-content">
<div class="roles-table">
<div class="table-header">
<div class="col-id">{{ strings.id }}</div>
<div class="col-name">{{ strings.name }}</div>
<div class="col-description">{{ strings.description }}</div>
<div class="col-created">{{ strings.created }}</div>
<div class="col-actions">{{ strings.actions }}</div>
</div>
<div v-for="role in roles" :key="role.id" class="table-row">
<div class="col-id">
<span class="role-id">{{ role.id }}</span>
<!-- Role list -->
<div v-else-if="roles.length > 0" class="roles-content">
<div class="roles-table">
<div class="table-header">
<div class="col-id">{{ strings.id }}</div>
<div class="col-name">{{ strings.name }}</div>
<div class="col-description">{{ strings.description }}</div>
<div class="col-created">{{ strings.created }}</div>
<div class="col-actions">{{ strings.actions }}</div>
</div>
<div class="col-name">
<span class="role-name" :class="getRoleClass(role.id)">{{ role.name }}</span>
</div>
<div v-for="role in roles" :key="role.id" class="table-row">
<div class="col-id">
<span class="role-id">{{ role.id }}</span>
</div>
<div class="col-description">
<span v-if="role.description" class="role-description">{{ role.description }}</span>
<span v-else class="muted">{{ strings.noDescription }}</span>
</div>
<div class="col-name">
<span class="role-name" :class="getRoleClass(role.id)">{{ role.name }}</span>
</div>
<div class="col-created">
<NcDateTime :timestamp="role.createdAt * 1000" />
</div>
<div class="col-description">
<span v-if="role.description" class="role-description">{{ role.description }}</span>
<span v-else class="muted">{{ strings.noDescription }}</span>
</div>
<div class="col-actions">
<NcActions>
<NcActionButton @click="editRole(role.id)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcActionButton>
<NcActionButton :disabled="isSystemRole(role.id)" @click="confirmDelete(role)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcActionButton>
</NcActions>
<div class="col-created">
<NcDateTime :timestamp="role.createdAt * 1000" />
</div>
<div class="col-actions">
<NcActions>
<NcActionButton @click="editRole(role.id)">
<template #icon>
<PencilIcon :size="20" />
</template>
{{ strings.edit }}
</NcActionButton>
<NcActionButton :disabled="isSystemRole(role.id)" @click="confirmDelete(role)">
<template #icon>
<DeleteIcon :size="20" />
</template>
{{ strings.delete }}
</NcActionButton>
</NcActions>
</div>
</div>
</div>
</div>
</div>
<!-- Empty state -->
<NcEmptyContent
v-else
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
>
<template #action>
<NcButton @click="createRole">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createRole }}
</NcButton>
</template>
</NcEmptyContent>
</div>
<!-- Empty state -->
<NcEmptyContent
v-else
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
>
<template #action>
<NcButton @click="createRole">
<template #icon>
<PlusIcon :size="20" />
</template>
{{ strings.createRole }}
</NcButton>
</template>
</NcEmptyContent>
</div>
</PageWrapper>
</template>
<script lang="ts">
@@ -112,6 +114,7 @@ import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import PlusIcon from '@icons/Plus.vue'
import PencilIcon from '@icons/Pencil.vue'
import DeleteIcon from '@icons/Delete.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
import type { Role } from '@/types'
@@ -128,6 +131,7 @@ export default defineComponent({
PlusIcon,
PencilIcon,
DeleteIcon,
PageWrapper,
},
data() {
return {

View File

@@ -1,145 +1,151 @@
<template>
<div class="admin-user-list">
<div class="page-header">
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
<PageWrapper :full-width="true">
<div class="admin-user-list">
<div class="page-header">
<h2>{{ strings.title }}</h2>
<p class="muted">{{ strings.subtitle }}</p>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Loading state -->
<div v-if="loading" class="center mt-16">
<NcLoadingIcon :size="32" />
<span class="muted ml-8">{{ strings.loading }}</span>
</div>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- Error state -->
<NcEmptyContent
v-else-if="error"
:title="strings.errorTitle"
:description="error"
class="mt-16"
>
<template #action>
<NcButton @click="refresh">{{ strings.retry }}</NcButton>
</template>
</NcEmptyContent>
<!-- User list -->
<div v-else-if="users.length > 0" class="users-content">
<div class="users-table">
<div class="table-header">
<div class="col-user">{{ strings.user }}</div>
<div class="col-posts">{{ strings.posts }}</div>
<div class="col-roles">{{ strings.roles }}</div>
<div class="col-joined">{{ strings.joined }}</div>
<div class="col-status">{{ strings.status }}</div>
</div>
<div
v-for="user in users"
:key="user.userId"
class="table-row"
:class="{ 'is-deleted': user.isDeleted }"
>
<div class="col-user">
<UserInfo :user-id="user.userId" :display-name="user.displayName" :avatar-size="40">
<template #meta>
<div class="user-id muted">@{{ user.userId }}</div>
</template>
</UserInfo>
<!-- User list -->
<div v-else-if="users.length > 0" class="users-content">
<div class="users-table">
<div class="table-header">
<div class="col-user">{{ strings.user }}</div>
<div class="col-posts">{{ strings.posts }}</div>
<div class="col-roles">{{ strings.roles }}</div>
<div class="col-joined">{{ strings.joined }}</div>
<div class="col-status">{{ strings.status }}</div>
</div>
<div class="col-posts">
<div class="post-stats">
<div class="stat-item">
<span class="stat-value">{{ user.threadCount }}</span>
<span class="stat-label muted">threads</span>
</div>
<div class="stat-divider">/</div>
<div class="stat-item">
<span class="stat-value">{{ user.postCount }}</span>
<span class="stat-label muted">posts</span>
</div>
</div>
</div>
<div class="col-roles">
<div v-if="editingUserId === user.userId" class="roles-editor">
<NcSelect
v-model="editingRoles"
:options="roleOptions"
:placeholder="strings.selectRoles"
:multiple="true"
label="name"
track-by="id"
input-label="name"
class="roles-select"
/>
<div class="edit-actions">
<NcButton @click="cancelEdit" :aria-label="strings.cancel" :title="strings.cancel">
<template #icon>
<CloseIcon :size="20" />
</template>
</NcButton>
<NcButton
variant="primary"
@click="saveRoles(user.userId)"
:aria-label="strings.save"
:title="strings.save"
>
<template #icon>
<CheckIcon :size="20" />
</template>
</NcButton>
</div>
</div>
<div v-else class="roles-display">
<div class="roles-list">
<span
v-for="roleId in user.roles"
:key="roleId"
class="role-badge"
:class="getRoleBadgeClass(roleId)"
>
{{ getRoleName(roleId) }}
</span>
<span v-if="user.roles.length === 0" class="muted">{{ strings.noRoles }}</span>
</div>
<NcButton
@click="startEdit(user.userId, user.roles)"
:aria-label="strings.edit"
:title="strings.edit"
>
<template #icon>
<PencilIcon :size="20" />
<div
v-for="user in users"
:key="user.userId"
class="table-row"
:class="{ 'is-deleted': user.isDeleted }"
>
<div class="col-user">
<UserInfo :user-id="user.userId" :display-name="user.displayName" :avatar-size="40">
<template #meta>
<div class="user-id muted">@{{ user.userId }}</div>
</template>
</NcButton>
</UserInfo>
</div>
</div>
<div class="col-joined">
<NcDateTime :timestamp="user.createdAt * 1000" />
</div>
<div class="col-posts">
<div class="post-stats">
<div class="stat-item">
<span class="stat-value">{{ user.threadCount }}</span>
<span class="stat-label muted">threads</span>
</div>
<div class="stat-divider">/</div>
<div class="stat-item">
<span class="stat-value">{{ user.postCount }}</span>
<span class="stat-label muted">posts</span>
</div>
</div>
</div>
<div class="col-status">
<span v-if="user.isDeleted" class="status-badge status-deleted">
{{ strings.deleted }}
</span>
<span v-else class="status-badge status-active">
{{ strings.active }}
</span>
<div class="col-roles">
<div v-if="editingUserId === user.userId" class="roles-editor">
<NcSelect
v-model="editingRoles"
:options="roleOptions"
:placeholder="strings.selectRoles"
:multiple="true"
label="name"
track-by="id"
input-label="name"
class="roles-select"
/>
<div class="edit-actions">
<NcButton
@click="cancelEdit"
:aria-label="strings.cancel"
:title="strings.cancel"
>
<template #icon>
<CloseIcon :size="20" />
</template>
</NcButton>
<NcButton
variant="primary"
@click="saveRoles(user.userId)"
:aria-label="strings.save"
:title="strings.save"
>
<template #icon>
<CheckIcon :size="20" />
</template>
</NcButton>
</div>
</div>
<div v-else class="roles-display">
<div class="roles-list">
<span
v-for="roleId in user.roles"
:key="roleId"
class="role-badge"
:class="getRoleBadgeClass(roleId)"
>
{{ getRoleName(roleId) }}
</span>
<span v-if="user.roles.length === 0" class="muted">{{ strings.noRoles }}</span>
</div>
<NcButton
@click="startEdit(user.userId, user.roles)"
:aria-label="strings.edit"
:title="strings.edit"
>
<template #icon>
<PencilIcon :size="20" />
</template>
</NcButton>
</div>
</div>
<div class="col-joined">
<NcDateTime :timestamp="user.createdAt * 1000" />
</div>
<div class="col-status">
<span v-if="user.isDeleted" class="status-badge status-deleted">
{{ strings.deleted }}
</span>
<span v-else class="status-badge status-active">
{{ strings.active }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Empty state -->
<NcEmptyContent
v-else
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
/>
</div>
<!-- Empty state -->
<NcEmptyContent
v-else
:title="strings.emptyTitle"
:description="strings.emptyDesc"
class="mt-16"
/>
</div>
</PageWrapper>
</template>
<script lang="ts">
@@ -153,6 +159,7 @@ import UserInfo from '@/components/UserInfo.vue'
import PencilIcon from '@icons/Pencil.vue'
import CheckIcon from '@icons/Check.vue'
import CloseIcon from '@icons/Close.vue'
import PageWrapper from '@/components/PageWrapper.vue'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
import type { Role } from '@/types'
@@ -186,6 +193,7 @@ export default defineComponent({
PencilIcon,
CheckIcon,
CloseIcon,
PageWrapper,
},
data() {
return {