feat: add Logs
This commit is contained in:
@@ -1,31 +0,0 @@
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'mws_theme'
|
||||
const DARK_CLASS = 'app-dark'
|
||||
|
||||
const isDark = ref(loadInitial())
|
||||
|
||||
function loadInitial(): boolean {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === 'dark') return true
|
||||
if (stored === 'light') return false
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
|
||||
function apply(value: boolean) {
|
||||
document.documentElement.classList.toggle(DARK_CLASS, value)
|
||||
}
|
||||
|
||||
apply(isDark.value)
|
||||
|
||||
watch(isDark, (value) => {
|
||||
apply(value)
|
||||
localStorage.setItem(STORAGE_KEY, value ? 'dark' : 'light')
|
||||
})
|
||||
|
||||
export function useTheme() {
|
||||
function toggle() {
|
||||
isDark.value = !isDark.value
|
||||
}
|
||||
return { isDark, toggle }
|
||||
}
|
||||
@@ -46,7 +46,7 @@
|
||||
<i class="pi pi-users"></i> Users
|
||||
</router-link>
|
||||
|
||||
<div v-if="auth.canView('masterdata') || auth.canView('permissions')">
|
||||
<div v-if="auth.canView('masterdata') || auth.canView('permissions') || auth.canView('actionlogs') || auth.canView('loginlogs')">
|
||||
<button
|
||||
class="nav-link w-full"
|
||||
:class="{ 'router-link-active': isSettingsActive }"
|
||||
@@ -65,6 +65,12 @@
|
||||
<router-link v-if="auth.canView('permissions')" class="nav-link nav-link-sub submenu-link" to="/settings/permissions" @click="handleNavClick">
|
||||
Permissions
|
||||
</router-link>
|
||||
<router-link v-if="auth.canView('actionlogs')" class="nav-link nav-link-sub submenu-link" to="/settings/actionlogs" @click="handleNavClick">
|
||||
Action Logs
|
||||
</router-link>
|
||||
<router-link v-if="auth.canView('loginlogs')" class="nav-link nav-link-sub submenu-link" to="/settings/loginlogs" @click="handleNavClick">
|
||||
Login Logs
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -88,20 +94,12 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Button
|
||||
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
|
||||
rounded
|
||||
text
|
||||
severity="secondary"
|
||||
:aria-label="theme.isDark.value ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
@click="theme.toggle"
|
||||
/>
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 overflow-auto">
|
||||
<div class="mx-auto h-full w-full max-w-[1200px] p-5 sm:p-7 lg:px-9 lg:py-8">
|
||||
<div class="mx-auto h-full w-full p-5 sm:p-7 lg:px-9 lg:py-8">
|
||||
<router-view />
|
||||
</div>
|
||||
</main>
|
||||
@@ -112,12 +110,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import UserMenu from '../components/UserMenu.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const theme = useTheme()
|
||||
|
||||
const desktopMq = window.matchMedia('(min-width: 1024px)')
|
||||
const isDesktop = ref(desktopMq.matches)
|
||||
|
||||
@@ -95,20 +95,12 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<Button
|
||||
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
|
||||
rounded
|
||||
text
|
||||
severity="secondary"
|
||||
:aria-label="theme.isDark.value ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
@click="theme.toggle"
|
||||
/>
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 overflow-auto">
|
||||
<div class="mx-auto h-full w-full max-w-[1200px] p-5 sm:p-7 lg:px-9 lg:py-8">
|
||||
<div class="mx-auto h-full w-full p-5 sm:p-7 lg:px-9 lg:py-8">
|
||||
<router-view />
|
||||
</div>
|
||||
</main>
|
||||
@@ -118,7 +110,6 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useTheme } from '../composables/useTheme'
|
||||
import { getProject, getProjects } from '../services/backend'
|
||||
import UserMenu from '../components/UserMenu.vue'
|
||||
import type { Project } from '../types'
|
||||
@@ -126,7 +117,6 @@ import type { Project } from '../types'
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const theme = useTheme()
|
||||
|
||||
const desktopMq = window.matchMedia('(min-width: 1024px)')
|
||||
const isDesktop = ref(desktopMq.matches)
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ app.use(router)
|
||||
app.use(PrimeVue, {
|
||||
theme: {
|
||||
preset: KrakenPreset,
|
||||
options: { darkModeSelector: '.app-dark' },
|
||||
options: { darkModeSelector: false },
|
||||
},
|
||||
})
|
||||
app.use(ToastService)
|
||||
|
||||
@@ -52,6 +52,8 @@ const router = createRouter({
|
||||
{ path: 'masterdata', name: 'settings-masterdata', component: () => import('../views/settings/masterdata/MasterDataView.vue'), meta: { title: 'Master Data', screenKey: 'masterdata' } },
|
||||
{ path: 'roles', name: 'settings-roles', component: () => import('../views/settings/roles/RolesView.vue'), meta: { title: 'Roles', screenKey: 'permissions' } },
|
||||
{ path: 'permissions', name: 'settings-permissions', component: () => import('../views/settings/permissions/PermissionsView.vue'), meta: { title: 'Permissions', screenKey: 'permissions' } },
|
||||
{ path: 'actionlogs', name: 'settings-actionlogs', component: () => import('../views/settings/actionlogs/ActionLogsView.vue'), meta: { title: 'Action Logs', screenKey: 'actionlogs' } },
|
||||
{ path: 'loginlogs', name: 'settings-loginlogs', component: () => import('../views/settings/loginlogs/LoginLogsView.vue'), meta: { title: 'Login Logs', screenKey: 'loginlogs' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -13,6 +13,10 @@ import type {
|
||||
PagedResult,
|
||||
UserRoleDetail,
|
||||
User,
|
||||
ActionLog,
|
||||
LoginLog,
|
||||
ActionLogFilters,
|
||||
LoginLogFilters,
|
||||
} from '../types'
|
||||
|
||||
export async function login(username: string, password: string): Promise<LoginResponse> {
|
||||
@@ -221,3 +225,13 @@ export async function getUserPermissions(userId: string): Promise<MenuItem[]> {
|
||||
const { data } = await api.get<MenuItem[]>(`/api/users/${userId}/permissions`)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getActionLogs(params: ActionLogFilters = {}): Promise<PagedResult<ActionLog>> {
|
||||
const { data } = await api.get<PagedResult<ActionLog>>('/api/audit/actions', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
export async function getLoginLogs(params: LoginLogFilters = {}): Promise<PagedResult<LoginLog>> {
|
||||
const { data } = await api.get<PagedResult<LoginLog>>('/api/audit/logins', { params })
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -155,3 +155,46 @@ export interface UserRoleDetail {
|
||||
export interface TaskCounts {
|
||||
[status: string]: number
|
||||
}
|
||||
|
||||
export interface ActionLog {
|
||||
id: string
|
||||
actorUserId: string
|
||||
actorUsername: string
|
||||
action: string
|
||||
entityType: string
|
||||
entityId: string | null
|
||||
entityName: string | null
|
||||
ipAddress: string | null
|
||||
userAgent: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface LoginLog {
|
||||
id: string
|
||||
username: string
|
||||
userId: string | null
|
||||
success: boolean
|
||||
failureReason: string | null
|
||||
ipAddress: string | null
|
||||
userAgent: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ActionLogFilters {
|
||||
from?: string
|
||||
to?: string
|
||||
actor?: string
|
||||
action?: string
|
||||
entityType?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface LoginLogFilters {
|
||||
from?: string
|
||||
to?: string
|
||||
username?: string
|
||||
success?: boolean
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
<template>
|
||||
<div class="grid min-h-dvh lg:grid-cols-[1.05fr_1fr]">
|
||||
<Button
|
||||
:icon="theme.isDark.value ? 'pi pi-sun' : 'pi pi-moon'"
|
||||
rounded
|
||||
text
|
||||
severity="secondary"
|
||||
class="!fixed !right-4 !top-4 z-10"
|
||||
:aria-label="theme.isDark.value ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
@click="theme.toggle"
|
||||
/>
|
||||
|
||||
<!-- Left panel: login image -->
|
||||
<section class="brand-panel relative hidden overflow-hidden lg:flex lg:items-center lg:justify-center">
|
||||
<img src="/login.png" alt="Login" class="absolute inset-0 h-full w-full object-cover" />
|
||||
@@ -52,10 +42,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { errorMessage } from '../../services/api'
|
||||
import { useTheme } from '../../composables/useTheme'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const theme = useTheme()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<AppTableShell title="Action Logs">
|
||||
<template #filters>
|
||||
<DatePicker v-model="dateRange" selectionMode="range" :manualInput="false" placeholder="Date range" showClear class="w-[260px]" @change="onFilterChange" />
|
||||
<IconField class="sm:w-[240px]">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model.trim="filters.actor" placeholder="Actor username" class="search-input w-full" @input="onTextFilter" />
|
||||
</IconField>
|
||||
<InputText v-model.trim="filters.action" placeholder="Action" class="search-input w-[180px]" @input="onTextFilter" />
|
||||
<InputText v-model.trim="filters.entityType" placeholder="Entity type" class="search-input w-[160px]" @input="onTextFilter" />
|
||||
</template>
|
||||
|
||||
<AppDataTable
|
||||
:value="logs"
|
||||
:loading="loading"
|
||||
:lazy="true"
|
||||
:paginator="true"
|
||||
:rows="pageSize"
|
||||
:totalRecords="totalCount"
|
||||
:first="first"
|
||||
@page="onPageChange"
|
||||
emptyMessage="No action logs"
|
||||
class="min-h-0 flex-1 min-w-[900px]"
|
||||
>
|
||||
<Column header="Time" style="width: 15%">
|
||||
<template #body="{ data }">{{ formatDate(data.createdAt) }}</template>
|
||||
</Column>
|
||||
<Column field="actorUsername" header="Actor" style="width: 12%" />
|
||||
<Column field="action" header="Action" style="width: 16%" />
|
||||
<Column field="entityType" header="Entity" style="width: 12%" />
|
||||
<Column header="Entity name" style="width: 20%">
|
||||
<template #body="{ data }">{{ data.entityName ?? '-' }}</template>
|
||||
</Column>
|
||||
<Column header="Entity id" style="width: 15%">
|
||||
<template #body="{ data }">
|
||||
<span class="font-mono text-xs">{{ data.entityId ? data.entityId.slice(0, 8) : '-' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="IP" style="width: 10%">
|
||||
<template #body="{ data }">{{ data.ipAddress ?? '-' }}</template>
|
||||
</Column>
|
||||
</AppDataTable>
|
||||
</AppTableShell>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getActionLogs } from '../../../services/backend'
|
||||
import { errorMessage } from '../../../services/api'
|
||||
import AppDataTable from '../../../components/AppDataTable.vue'
|
||||
import AppTableShell from '../../../components/AppTableShell.vue'
|
||||
import type { ActionLog } from '../../../types'
|
||||
import type { DataTablePageEvent } from 'primevue/datatable'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const logs = ref<ActionLog[]>([])
|
||||
const loading = ref(false)
|
||||
const dateRange = ref<Date[] | null>(null)
|
||||
const filters = reactive<{ actor: string; action: string; entityType: string }>({ actor: '', action: '', entityType: '' })
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const totalCount = ref(0)
|
||||
const first = computed(() => (page.value - 1) * pageSize.value)
|
||||
|
||||
async function loadLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getActionLogs({
|
||||
from: dateRange.value?.[0]?.toISOString(),
|
||||
to: endOfDay(dateRange.value?.[1]),
|
||||
actor: filters.actor || undefined,
|
||||
action: filters.action || undefined,
|
||||
entityType: filters.entityType || undefined,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
})
|
||||
logs.value = res.items
|
||||
totalCount.value = res.totalCount
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPageChange(event: DataTablePageEvent) {
|
||||
page.value = event.page + 1
|
||||
pageSize.value = event.rows
|
||||
void loadLogs()
|
||||
}
|
||||
|
||||
let textTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function onTextFilter() {
|
||||
clearTimeout(textTimer)
|
||||
textTimer = setTimeout(() => {
|
||||
page.value = 1
|
||||
void loadLogs()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
page.value = 1
|
||||
void loadLogs()
|
||||
}
|
||||
|
||||
function endOfDay(date?: Date): string | undefined {
|
||||
if (!date) return undefined
|
||||
const end = new Date(date)
|
||||
end.setHours(23, 59, 59, 999)
|
||||
return end.toISOString()
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString()
|
||||
}
|
||||
|
||||
onMounted(loadLogs)
|
||||
</script>
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col">
|
||||
<AppTableShell title="Login Logs">
|
||||
<template #filters>
|
||||
<DatePicker v-model="dateRange" selectionMode="range" :manualInput="false" placeholder="Date range" showClear class="w-[260px]" @change="onFilterChange" />
|
||||
<IconField class="sm:w-[240px]">
|
||||
<InputIcon class="pi pi-user" />
|
||||
<InputText v-model.trim="filters.username" placeholder="Username" class="search-input w-full" @input="onTextFilter" />
|
||||
</IconField>
|
||||
<Select v-model="filters.success" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="Result" showClear class="w-[160px]" @change="onFilterChange" />
|
||||
</template>
|
||||
|
||||
<AppDataTable
|
||||
:value="logs"
|
||||
:loading="loading"
|
||||
:lazy="true"
|
||||
:paginator="true"
|
||||
:rows="pageSize"
|
||||
:totalRecords="totalCount"
|
||||
:first="first"
|
||||
@page="onPageChange"
|
||||
emptyMessage="No login logs"
|
||||
class="min-h-0 flex-1 min-w-[800px]"
|
||||
>
|
||||
<Column header="Time" style="width: 15%">
|
||||
<template #body="{ data }">{{ formatDate(data.createdAt) }}</template>
|
||||
</Column>
|
||||
<Column field="username" header="Username" style="width: 15%" />
|
||||
<Column header="Result" style="width: 12%">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.success ? 'Success' : 'Failed'" :severity="data.success ? 'success' : 'danger'" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Reason" style="width: 20%">
|
||||
<template #body="{ data }">{{ data.failureReason ?? '-' }}</template>
|
||||
</Column>
|
||||
<Column header="IP" style="width: 15%">
|
||||
<template #body="{ data }">{{ data.ipAddress ?? '-' }}</template>
|
||||
</Column>
|
||||
<Column header="User agent" style="width: 23%">
|
||||
<template #body="{ data }">
|
||||
<span class="block truncate" :title="data.userAgent ?? ''">{{ data.userAgent ?? '-' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</AppDataTable>
|
||||
</AppTableShell>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getLoginLogs } from '../../../services/backend'
|
||||
import { errorMessage } from '../../../services/api'
|
||||
import AppDataTable from '../../../components/AppDataTable.vue'
|
||||
import AppTableShell from '../../../components/AppTableShell.vue'
|
||||
import type { LoginLog } from '../../../types'
|
||||
import type { DataTablePageEvent } from 'primevue/datatable'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const logs = ref<LoginLog[]>([])
|
||||
const loading = ref(false)
|
||||
const dateRange = ref<Date[] | null>(null)
|
||||
const filters = reactive<{ username: string; success: boolean | null }>({ username: '', success: null })
|
||||
const statusOptions = [
|
||||
{ label: 'Success', value: true },
|
||||
{ label: 'Failed', value: false },
|
||||
]
|
||||
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const totalCount = ref(0)
|
||||
const first = computed(() => (page.value - 1) * pageSize.value)
|
||||
|
||||
async function loadLogs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getLoginLogs({
|
||||
from: dateRange.value?.[0]?.toISOString(),
|
||||
to: endOfDay(dateRange.value?.[1]),
|
||||
username: filters.username || undefined,
|
||||
success: filters.success ?? undefined,
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
})
|
||||
logs.value = res.items
|
||||
totalCount.value = res.totalCount
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: errorMessage(e), life: 4000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPageChange(event: DataTablePageEvent) {
|
||||
page.value = event.page + 1
|
||||
pageSize.value = event.rows
|
||||
void loadLogs()
|
||||
}
|
||||
|
||||
let textTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function onTextFilter() {
|
||||
clearTimeout(textTimer)
|
||||
textTimer = setTimeout(() => {
|
||||
page.value = 1
|
||||
void loadLogs()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
page.value = 1
|
||||
void loadLogs()
|
||||
}
|
||||
|
||||
function endOfDay(date?: Date): string | undefined {
|
||||
if (!date) return undefined
|
||||
const end = new Date(date)
|
||||
end.setHours(23, 59, 59, 999)
|
||||
return end.toISOString()
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleString()
|
||||
}
|
||||
|
||||
onMounted(loadLogs)
|
||||
</script>
|
||||
@@ -117,6 +117,8 @@ const ALL_SCREENS = [
|
||||
{ key: 'users', label: 'Users' },
|
||||
{ key: 'permissions', label: 'Permissions' },
|
||||
{ key: 'masterdata', label: 'Master Data' },
|
||||
{ key: 'actionlogs', label: 'Action Logs' },
|
||||
{ key: 'loginlogs', label: 'Login Logs' },
|
||||
]
|
||||
|
||||
const filteredRoles = computed(() => {
|
||||
|
||||
@@ -54,14 +54,15 @@
|
||||
<span v-if="isMine(task)" class="mine-badge">My task</span>
|
||||
</div>
|
||||
<div v-if="task.description" class="muted-note mt-1 line-clamp-2 text-[0.85rem]">{{ task.description }}</div>
|
||||
<div class="mt-2 text-[0.8rem]" v-if="task.assigneeName">
|
||||
<span class="flex items-center gap-1 muted-note">
|
||||
<div class="mt-2 flex flex-col gap-1.5 text-[0.8rem]">
|
||||
<span v-if="task.assigneeName" class="meta-chip meta-chip-user self-start">
|
||||
<i class="pi pi-user text-[0.7rem]"></i> {{ task.assigneeName }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-[0.8rem]" v-if="task.dueDate">
|
||||
<span class="flex items-center gap-1 muted-note">
|
||||
<i class="pi pi-calendar text-[0.7rem]"></i> {{ formatDate(task.dueDate) }}
|
||||
<span
|
||||
class="meta-chip self-start"
|
||||
:class="task.dueDate ? 'meta-chip-due' : 'meta-chip-nodue'"
|
||||
>
|
||||
<i class="pi pi-calendar text-[0.7rem]"></i> {{ task.dueDate ? formatDate(task.dueDate) : 'No Due date' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -275,6 +276,32 @@ watch(
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.meta-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.meta-chip-user {
|
||||
background: var(--primary-soft);
|
||||
color: var(--primary);
|
||||
}
|
||||
.meta-chip-due {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
color: #b45309;
|
||||
}
|
||||
.meta-chip-nodue {
|
||||
background: rgba(110, 107, 102, 0.1);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.board-col.drag-over {
|
||||
background: color-mix(in srgb, var(--primary) 6%, transparent);
|
||||
box-shadow: inset 0 0 0 2px var(--primary);
|
||||
|
||||
Reference in New Issue
Block a user