732 lines
19 KiB
Vue
732 lines
19 KiB
Vue
<template>
|
|
<private-view title="Feedback Commander">
|
|
<template #headline>
|
|
<v-breadcrumb :items="[{ name: 'Feedback', to: '/feedback-commander' }]" />
|
|
</template>
|
|
|
|
<template #title-outer:after>
|
|
<v-chip v-if="loading" label color="blue" small>Loading...</v-chip>
|
|
<v-chip v-else-if="fetchError" label color="red" small>Fetch Error</v-chip>
|
|
<v-chip v-else label color="green" small>{{ items.length }} Items</v-chip>
|
|
</template>
|
|
|
|
<template #navigation>
|
|
<div class="sidebar-header">
|
|
<v-text-overflow text="Websites" class="header-text" />
|
|
</div>
|
|
<v-list nav>
|
|
<v-list-item
|
|
:active="currentProject === 'all'"
|
|
@click="currentProject = 'all'"
|
|
clickable
|
|
>
|
|
<v-list-item-icon><v-icon name="language" /></v-list-item-icon>
|
|
<v-list-item-content><v-text-overflow text="All Projects" /></v-list-item-content>
|
|
</v-list-item>
|
|
|
|
<v-list-item
|
|
v-for="project in projects"
|
|
:key="project"
|
|
:active="currentProject === project"
|
|
@click="currentProject = project"
|
|
clickable
|
|
>
|
|
<v-list-item-icon><v-icon name="public" color="var(--primary)" /></v-list-item-icon>
|
|
<v-list-item-content><v-text-overflow :text="project || 'Unknown'" /></v-list-item-content>
|
|
</v-list-item>
|
|
</v-list>
|
|
</template>
|
|
|
|
<div class="feedback-container">
|
|
<div v-if="!items.length && !loading && !fetchError" class="empty-state">
|
|
<v-info icon="inbox" title="Clean Inbox" center>
|
|
All feedback has been processed. Great job!
|
|
</v-info>
|
|
</div>
|
|
|
|
<div v-if="fetchError" class="empty-state">
|
|
<v-info icon="error" title="Fetch Failed" :description="fetchError" center />
|
|
<v-button @click="fetchData" secondary small>Retry</v-button>
|
|
</div>
|
|
|
|
<div class="operational-layout" v-else-if="items.length">
|
|
<!-- Detailed Triage Lane -->
|
|
<aside class="triage-lane">
|
|
<div class="lane-header">
|
|
<v-select
|
|
v-model="currentStatusFilter"
|
|
:items="statusOptions"
|
|
small
|
|
placeholder="Status Filter"
|
|
/>
|
|
</div>
|
|
<div class="lane-content scrollbar">
|
|
<TransitionGroup name="list">
|
|
<div
|
|
v-for="item in filteredItems"
|
|
:key="item.id"
|
|
class="feedback-card"
|
|
:class="{ active: selectedItem?.id === item.id }"
|
|
@click="selectItem(item)"
|
|
>
|
|
<div class="card-status-bar" :style="{ background: getStatusColor(item.status || 'open') }"></div>
|
|
<div class="card-body">
|
|
<header class="card-header">
|
|
<span class="card-user">{{ item.user_name }}</span>
|
|
<span class="card-date">{{ formatDate(item.date_created || item.id) }}</span>
|
|
</header>
|
|
<div class="card-text">{{ item.text }}</div>
|
|
<footer class="card-footer">
|
|
<div class="meta-tags">
|
|
<v-chip x-small outline>{{ item.company?.name || item.project }}</v-chip>
|
|
<v-icon :name="item.type === 'bug' ? 'bug_report' : 'lightbulb'" :color="item.type === 'bug' ? '#E91E63' : '#FFC107'" small />
|
|
</div>
|
|
<v-icon v-if="selectedItem?.id === item.id" name="chevron_right" small />
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
</TransitionGroup>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- Elaborated Master-Detail Desk -->
|
|
<main class="processing-desk scrollbar">
|
|
<Transition name="fade" mode="out-in">
|
|
<div v-if="selectedItem" :key="selectedItem.id" class="desk-content">
|
|
<header class="desk-header">
|
|
<div class="headline-group">
|
|
<div class="status-indicator">
|
|
<div class="status-dot" :style="{ background: getStatusColor(selectedItem.status || 'open') }"></div>
|
|
<span class="status-text">{{ capitalize(selectedItem.status || 'open') }}</span>
|
|
</div>
|
|
<h2>{{ selectedItem.user_name }}'s Submission</h2>
|
|
</div>
|
|
<div class="header-actions">
|
|
<v-button primary @click="openDeepLink(selectedItem)">
|
|
<v-icon name="open_in_new" left /> Open & Highlight
|
|
</v-button>
|
|
<v-select
|
|
v-model="selectedItem.status"
|
|
:items="statuses"
|
|
inline
|
|
@update:model-value="updateStatus"
|
|
/>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="desk-grid">
|
|
<!-- Message Container -->
|
|
<div class="main-column">
|
|
<v-card class="content-card">
|
|
<v-card-title>
|
|
<v-icon name="format_quote" left />
|
|
Feedback Content
|
|
</v-card-title>
|
|
<v-card-text class="feedback-body">
|
|
<div v-if="selectedItem.screenshot" class="visual-proof">
|
|
<label class="proof-label"><v-icon name="photo" x-small /> Element Snapshot</label>
|
|
<img :src="getAssetUrl(selectedItem.screenshot)" class="screenshot-img" />
|
|
</div>
|
|
<div class="main-text">{{ selectedItem.text }}</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<section class="reply-section">
|
|
<div class="section-divider">
|
|
<v-divider />
|
|
<span class="divider-label">Internal Communication</span>
|
|
<v-divider />
|
|
</div>
|
|
|
|
<div class="thread">
|
|
<TransitionGroup name="thread-list">
|
|
<div v-for="reply in comments" :key="reply.id" class="reply-bubble">
|
|
<header class="reply-header">
|
|
<span class="reply-user">{{ reply.user_name || 'System' }}</span>
|
|
<span v-if="reply.person" class="reply-person">({{ reply.person.first_name }} {{ reply.person.last_name }})</span>
|
|
<span class="reply-date">{{ formatDate(reply.date_created || reply.id) }}</span>
|
|
</header>
|
|
<div class="reply-text">{{ reply.text }}</div>
|
|
</div>
|
|
</TransitionGroup>
|
|
<div v-if="!comments.length" class="empty-state-mini">
|
|
<v-icon name="auto_awesome" small /> No replies yet. Start the thread.
|
|
</div>
|
|
</div>
|
|
|
|
<div class="composer">
|
|
<v-textarea v-model="replyText" placeholder="Compose internal response..." auto-grow />
|
|
<div class="composer-actions">
|
|
<v-button secondary :loading="sending" @click="sendReply">Post Reply</v-button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<!-- Technical Sidebar -->
|
|
<aside class="meta-column">
|
|
<v-card class="meta-card">
|
|
<v-card-title>Context</v-card-title>
|
|
<v-card-text class="meta-list">
|
|
<div class="meta-item">
|
|
<label><v-icon name="business" x-small /> Organisation / Firma</label>
|
|
<strong>{{ selectedItem.company?.name || selectedItem.project }}</strong>
|
|
</div>
|
|
<div v-if="selectedItem.person" class="meta-item">
|
|
<label><v-icon name="person" x-small /> Zentrale Person</label>
|
|
<strong>{{ selectedItem.person.first_name }} {{ selectedItem.person.last_name }}</strong>
|
|
</div>
|
|
<div class="meta-item">
|
|
<label><v-icon name="link" x-small /> Source Path</label>
|
|
<span class="truncate-path" :title="selectedItem.url">{{ formatUrl(selectedItem.url) }}</span>
|
|
<v-button icon small @click="openExternal(selectedItem.url)"><v-icon name="launch" /></v-button>
|
|
</div>
|
|
<v-divider />
|
|
<div class="meta-item">
|
|
<label><v-icon name="layers" x-small /> Element Trace</label>
|
|
<code class="trace-code">{{ selectedItem.selector || 'Body' }}</code>
|
|
</div>
|
|
<div class="meta-item">
|
|
<label><v-icon name="location_searching" x-small /> Precise Mark</label>
|
|
<span class="coords">X: {{ Math.round(selectedItem.x) }}px / Y: {{ Math.round(selectedItem.y) }}px</span>
|
|
</div>
|
|
<div class="meta-item">
|
|
<label><v-icon name="fingerprint" x-small /> Reference ID</label>
|
|
<code class="id-code">{{ selectedItem.id }}</code>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<div class="help-box">
|
|
<v-icon name="help_outline" x-small />
|
|
<span>Click "Open & Highlight" to jump directly to this element on the live site.</span>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
<div v-else class="no-selection-desk">
|
|
<v-info icon="touch_app" title="Select Feedback" center>
|
|
Choose an entry from the triage list to view details and process.
|
|
</v-info>
|
|
</div>
|
|
</Transition>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</private-view>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue';
|
|
import { useApi } from '@directus/extensions-sdk';
|
|
|
|
const api = useApi();
|
|
const items = ref([]);
|
|
const comments = ref([]);
|
|
const loading = ref(true);
|
|
const fetchError = ref(null);
|
|
const sending = ref(false);
|
|
const selectedItem = ref(null);
|
|
const currentProject = ref('all');
|
|
const currentStatusFilter = ref('open');
|
|
const replyText = ref('');
|
|
|
|
const statuses = [
|
|
{ text: 'Open', value: 'open', icon: 'warning', color: '#E91E63' },
|
|
{ text: 'In Progress', value: 'in_progress', icon: 'play_arrow', color: '#2196F3' },
|
|
{ text: 'Resolved', value: 'resolved', icon: 'check_circle', color: '#4CAF50' }
|
|
];
|
|
|
|
const statusOptions = [
|
|
{ text: 'All Statuses', value: 'all' },
|
|
...statuses
|
|
];
|
|
|
|
const projects = computed(() => {
|
|
const projSet = new Set(items.value.map(i => i.company?.name || i.project).filter(Boolean));
|
|
return Array.from(projSet).sort();
|
|
});
|
|
|
|
const filteredItems = computed(() => {
|
|
return items.value.filter(item => {
|
|
const projectName = item.company?.name || item.project;
|
|
const matchProject = currentProject.value === 'all' || projectName === currentProject.value;
|
|
const status = item.status || 'open';
|
|
const matchStatus = currentStatusFilter.value === 'all' || status === currentStatusFilter.value;
|
|
return matchProject && matchStatus;
|
|
});
|
|
});
|
|
|
|
async function fetchData() {
|
|
loading.value = true;
|
|
fetchError.value = null;
|
|
try {
|
|
const response = await api.get('/items/visual_feedback', {
|
|
params: {
|
|
sort: '-date_created,-id',
|
|
limit: 300,
|
|
fields: ['*', 'company.*', 'person.*']
|
|
}
|
|
});
|
|
items.value = response.data.data;
|
|
} catch (e: any) {
|
|
fetchError.value = e.message;
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function selectItem(item) {
|
|
selectedItem.value = null;
|
|
setTimeout(async () => {
|
|
selectedItem.value = item;
|
|
comments.value = [];
|
|
try {
|
|
const response = await api.get('/items/visual_feedback_comments', {
|
|
params: {
|
|
filter: { feedback_id: { _eq: item.id } },
|
|
sort: '-date_created,-id',
|
|
fields: ['*', 'person.*']
|
|
}
|
|
});
|
|
comments.value = response.data.data;
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}, 10);
|
|
}
|
|
|
|
async function updateStatus(val) {
|
|
if (!selectedItem.value) return;
|
|
try {
|
|
await api.patch(`/items/visual_feedback/${selectedItem.value.id}`, {
|
|
status: val
|
|
});
|
|
fetchData();
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
async function sendReply() {
|
|
if (!replyText.value.trim() || !selectedItem.value) return;
|
|
sending.value = true;
|
|
try {
|
|
const response = await api.post('/items/visual_feedback_comments', {
|
|
feedback_id: selectedItem.value.id,
|
|
user_name: 'Operator',
|
|
text: replyText.value
|
|
});
|
|
comments.value.unshift(response.data.data);
|
|
replyText.value = '';
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
sending.value = false;
|
|
}
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr || typeof dateStr === 'number') return 'Legacy';
|
|
return new Date(dateStr).toLocaleDateString() + ' ' + new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
}
|
|
|
|
function formatUrl(url) {
|
|
if (!url) return '';
|
|
return url.replace(/^https?:\/\//, '');
|
|
}
|
|
|
|
function capitalize(s) {
|
|
return s.charAt(0).toUpperCase() + s.slice(1).replace('_', ' ');
|
|
}
|
|
|
|
function getDeepLinkUrl(item) {
|
|
if (!item || !item.url) return '';
|
|
try {
|
|
const url = new URL(item.url);
|
|
url.searchParams.set('fb_id', item.id);
|
|
return url.toString();
|
|
} catch (e) {
|
|
return item.url + '?fb_id=' + item.id;
|
|
}
|
|
}
|
|
|
|
function openDeepLink(item) {
|
|
const url = getDeepLinkUrl(item);
|
|
if (url) window.open(url, '_blank');
|
|
}
|
|
|
|
function openExternal(url) {
|
|
if (url) window.open(url, '_blank');
|
|
}
|
|
|
|
function getAssetUrl(id) {
|
|
if (!id) return '';
|
|
return `/assets/${id}`;
|
|
}
|
|
|
|
function getStatusColor(status) {
|
|
const s = statuses.find(st => st.value === status);
|
|
return s ? s.color : 'var(--foreground-subdued)';
|
|
}
|
|
|
|
onMounted(() => {
|
|
fetchData();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.feedback-container {
|
|
height: calc(100vh - 64px);
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--background-subdued);
|
|
}
|
|
|
|
.operational-layout {
|
|
display: flex;
|
|
height: 100%;
|
|
}
|
|
|
|
/* Triage Lane Polish */
|
|
.triage-lane {
|
|
width: 360px;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--background-normal);
|
|
border-right: 1px solid var(--border-normal);
|
|
box-shadow: 2px 0 8px rgba(0,0,0,0.02);
|
|
}
|
|
|
|
.lane-header {
|
|
padding: 16px;
|
|
background: var(--background-normal);
|
|
border-bottom: 1px solid var(--border-normal);
|
|
}
|
|
|
|
.lane-content {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.feedback-card {
|
|
background: var(--background-normal);
|
|
border: 1px solid var(--border-subdued);
|
|
border-radius: 8px;
|
|
display: flex;
|
|
overflow: hidden;
|
|
cursor: pointer;
|
|
transition: all var(--transition);
|
|
}
|
|
|
|
.feedback-card:hover {
|
|
border-color: var(--border-normal);
|
|
background: var(--background-subdued);
|
|
transform: translateY(-1px);
|
|
box-shadow: 0 4px 8px rgba(0,0,0,0.04);
|
|
}
|
|
|
|
.feedback-card.active {
|
|
border-color: var(--primary);
|
|
background: var(--background-accent);
|
|
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.1);
|
|
}
|
|
|
|
.card-status-bar {
|
|
width: 4px;
|
|
}
|
|
|
|
.card-body {
|
|
flex: 1;
|
|
padding: 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.card-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.card-user { font-weight: bold; color: var(--foreground-normal); }
|
|
.card-date { color: var(--foreground-subdued); }
|
|
|
|
.card-text {
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
color: var(--foreground-normal);
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.card-footer {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.meta-tags {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
}
|
|
|
|
/* Processing Desk Refinement */
|
|
.processing-desk {
|
|
flex: 1;
|
|
height: 100%;
|
|
overflow-y: auto;
|
|
padding: 32px;
|
|
}
|
|
|
|
.desk-content {
|
|
max-width: 1100px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.desk-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-end;
|
|
margin-bottom: 32px;
|
|
border-bottom: 2px solid var(--border-normal);
|
|
padding-bottom: 20px;
|
|
}
|
|
|
|
.headline-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.status-indicator {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
text-transform: uppercase;
|
|
color: var(--foreground-subdued);
|
|
}
|
|
|
|
.status-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.status-text { letter-spacing: 0.5px; }
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 16px;
|
|
align-items: center;
|
|
}
|
|
|
|
.desk-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 300px;
|
|
gap: 24px;
|
|
align-items: start;
|
|
}
|
|
|
|
.content-card {
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.feedback-body {
|
|
font-size: 18px;
|
|
line-height: 1.6;
|
|
padding: 24px;
|
|
color: var(--foreground-normal);
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 20px;
|
|
}
|
|
|
|
.visual-proof {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.proof-label {
|
|
font-size: 10px;
|
|
text-transform: uppercase;
|
|
font-weight: 800;
|
|
color: var(--foreground-subdued);
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
.screenshot-img {
|
|
width: 100%;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border-normal);
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
|
background: var(--background-subdued);
|
|
}
|
|
|
|
.main-text {
|
|
white-space: pre-wrap;
|
|
}
|
|
|
|
.reply-section {
|
|
margin-top: 40px;
|
|
}
|
|
|
|
.section-divider {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.divider-label {
|
|
font-size: 11px;
|
|
text-transform: uppercase;
|
|
font-weight: 800;
|
|
color: var(--foreground-subdued);
|
|
white-space: nowrap;
|
|
letter-spacing: 1px;
|
|
}
|
|
|
|
.thread {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
margin-bottom: 32px;
|
|
}
|
|
|
|
.reply-bubble {
|
|
padding: 16px;
|
|
border-radius: 12px;
|
|
background: var(--background-normal);
|
|
border: 1px solid var(--border-subdued);
|
|
}
|
|
|
|
.reply-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
font-size: 11px;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.reply-user { font-weight: 800; color: var(--primary); }
|
|
.reply-date { color: var(--foreground-subdued); }
|
|
|
|
.reply-text { font-size: 14px; line-height: 1.5; }
|
|
|
|
.composer {
|
|
background: var(--background-normal);
|
|
border: 1px solid var(--border-normal);
|
|
border-radius: 12px;
|
|
padding: 16px;
|
|
}
|
|
|
|
.composer-actions {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
margin-top: 12px;
|
|
}
|
|
|
|
.meta-card {
|
|
border-radius: 12px;
|
|
}
|
|
|
|
.meta-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
padding: 16px;
|
|
}
|
|
|
|
.meta-item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.meta-item label {
|
|
font-size: 10px;
|
|
text-transform: uppercase;
|
|
font-weight: bold;
|
|
color: var(--foreground-subdued);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.truncate-path {
|
|
color: var(--primary);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.trace-code, .id-code {
|
|
background: var(--background-subdued);
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
font-size: 11px;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.coords { font-weight: bold; font-family: var(--family-monospace); }
|
|
|
|
.help-box {
|
|
margin-top: 20px;
|
|
padding: 16px;
|
|
background: rgba(var(--primary-rgb), 0.05);
|
|
border-radius: 12px;
|
|
font-size: 12px;
|
|
color: var(--primary);
|
|
display: flex;
|
|
gap: 8px;
|
|
line-height: 1.4;
|
|
}
|
|
|
|
.no-selection-desk {
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.empty-state-mini {
|
|
text-align: center;
|
|
padding: 24px;
|
|
font-size: 12px;
|
|
color: var(--foreground-subdued);
|
|
background: var(--background-subdued);
|
|
border-radius: 12px;
|
|
border: 1px dashed var(--border-normal);
|
|
}
|
|
|
|
/* Animations */
|
|
.list-enter-active, .list-leave-active { transition: all 0.3s ease; }
|
|
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(-20px); }
|
|
|
|
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
|
|
.fade-enter-from { opacity: 0; transform: translateY(10px); }
|
|
.fade-leave-to { opacity: 0; transform: translateY(-10px); }
|
|
|
|
.thread-list-enter-active { transition: all 0.4s ease; transform-origin: top; }
|
|
.thread-list-enter-from { opacity: 0; transform: scaleY(0.9); }
|
|
|
|
.scrollbar::-webkit-scrollbar { width: 6px; }
|
|
.scrollbar::-webkit-scrollbar-track { background: transparent; }
|
|
.scrollbar::-webkit-scrollbar-thumb { background: var(--border-subdued); border-radius: 3px; }
|
|
.scrollbar::-webkit-scrollbar-thumb:hover { background: var(--border-normal); }
|
|
</style>
|