All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 6s
Monorepo Pipeline / 🧪 Test (push) Successful in 56s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m22s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m51s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
Summary of changes: - Corrected Directus extensions to use 'vue-router' for 'useRouter' instead of '@directus/extensions-sdk' (Fixed runtime crash). - Standardized extension folder structure and moved built extensions to the root 'directus/extensions' directory. - Updated 'scripts/sync-extensions.sh' and 'scripts/validate-extensions.sh' for better extension management. - Added 'scripts/validate-sdk-imports.sh' as a safeguard against future invalid SDK imports. - Integrated import validation into the '.husky/pre-push' hook. - Standardized Docker restart policies and network configurations in 'cms-infra/docker-compose.yml'. - Updated tracked 'data.db' with the correct 'module_bar' settings to ensure extension visibility. - Cleaned up legacy files and consolidated extension package source code. This commit captures the full state of the repository after resolving the 'missing extensions' issue.
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>
|