Initial commit: Nexus Mission Control Platform
- ASP.NET Core 10 Backend (JWT Auth, Agent config API) - Vue 3 Frontend (Dashboard, Team, Agents, Config Editor) - PostgreSQL Database - Docker Compose setup - Mission Control Dashboard redesign
This commit is contained in:
@@ -0,0 +1,630 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { Bot, CheckCircle2, Clock3, MessageSquareText, Send, ShieldAlert, Zap, ChevronLeft, ChevronRight, Edit2, Save, X, Trash2 } from '@lucide/vue'
|
||||
import type { AgentInfo, OperationsSnapshot, RoutingTarget } from '../types'
|
||||
import { TASK_STATES } from '../types'
|
||||
import { apiFetch } from '../services/api'
|
||||
import { useOperationsStore } from '../stores/operations'
|
||||
|
||||
const props = defineProps<{ view: string; snapshot: OperationsSnapshot; routing: RoutingTarget[] }>()
|
||||
const emit = defineEmits<{
|
||||
createProject: [name: string]
|
||||
createTask: [title: string, priority: string]
|
||||
updateTaskState: [id: string, state: string]
|
||||
}>()
|
||||
const store = useOperationsStore()
|
||||
const agents = ref<AgentInfo[]>([])
|
||||
const agentsLoading = ref(false)
|
||||
|
||||
async function loadAgents() {
|
||||
if (agentsLoading.value) return
|
||||
agentsLoading.value = true
|
||||
agents.value = await store.fetchAgents()
|
||||
agentsLoading.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.view === 'Agents') loadAgents()
|
||||
})
|
||||
|
||||
watch(() => props.view, (v) => {
|
||||
if (v === 'Agents') loadAgents()
|
||||
})
|
||||
|
||||
const newProject = ref('')
|
||||
const newTask = ref('')
|
||||
const message = ref('')
|
||||
const chatMessages = ref<Array<{ role: 'owner' | 'iris' | 'error'; content: string }>>([])
|
||||
const chatPending = ref(false)
|
||||
const conversationId = ref(localStorage.getItem('nexus-conversation-id') ?? crypto.randomUUID())
|
||||
localStorage.setItem('nexus-conversation-id', conversationId.value)
|
||||
|
||||
// Task editing state
|
||||
const editingTaskId = ref<string | null>(null)
|
||||
|
||||
// Task approval / rejection state
|
||||
const approvingTaskId = ref<string | null>(null)
|
||||
const taskActionError = ref('')
|
||||
|
||||
async function handleApproveTask(id: string) {
|
||||
approvingTaskId.value = id
|
||||
taskActionError.value = ''
|
||||
try {
|
||||
await store.approveTask(id)
|
||||
} catch (e) {
|
||||
taskActionError.value = e instanceof Error ? e.message : 'Failed to approve task'
|
||||
} finally {
|
||||
approvingTaskId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRejectTask(id: string) {
|
||||
approvingTaskId.value = id
|
||||
taskActionError.value = ''
|
||||
try {
|
||||
await store.rejectTask(id)
|
||||
} catch (e) {
|
||||
taskActionError.value = e instanceof Error ? e.message : 'Failed to reject task'
|
||||
} finally {
|
||||
approvingTaskId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Task deletion state
|
||||
const deletingTaskId = ref<string | null>(null)
|
||||
const deleteError = ref('')
|
||||
|
||||
async function confirmDeleteTask(id: string) {
|
||||
deleteError.value = ''
|
||||
try {
|
||||
await store.deleteTask(id)
|
||||
deletingTaskId.value = null
|
||||
} catch (e) {
|
||||
deleteError.value = e instanceof Error ? e.message : 'Failed to delete task'
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDeleteTask() {
|
||||
deletingTaskId.value = null
|
||||
deleteError.value = ''
|
||||
}
|
||||
const editTaskTitle = ref('')
|
||||
const editTaskPriority = ref('')
|
||||
const editTaskProjectId = ref<string | null>(null)
|
||||
|
||||
// Activity filtering and pagination
|
||||
const activityTypeFilter = ref('')
|
||||
const activitySort = ref('newest')
|
||||
const activityPage = ref(1)
|
||||
const activityPageSize = 20
|
||||
const activityTotalPages = ref(1)
|
||||
const activityTotalCount = ref(0)
|
||||
|
||||
const columns = computed(() =>
|
||||
TASK_STATES.map(state => ({ name: state, items: props.snapshot.tasks.filter(x => x.state === state) })))
|
||||
|
||||
const availableTypes = computed(() => {
|
||||
const types = new Set(props.snapshot.activity.map(e => e.type))
|
||||
return Array.from(types)
|
||||
})
|
||||
|
||||
const filteredActivity = computed(() => {
|
||||
let items = [...props.snapshot.activity]
|
||||
if (activityTypeFilter.value) {
|
||||
items = items.filter(e => e.type === activityTypeFilter.value)
|
||||
}
|
||||
if (activitySort.value === 'oldest') {
|
||||
items.reverse()
|
||||
}
|
||||
const total = items.length
|
||||
activityTotalCount.value = total
|
||||
activityTotalPages.value = Math.max(1, Math.ceil(total / activityPageSize))
|
||||
const start = (activityPage.value - 1) * activityPageSize
|
||||
return items.slice(start, start + activityPageSize)
|
||||
})
|
||||
|
||||
watch(activityTypeFilter, () => { activityPage.value = 1 })
|
||||
watch(activitySort, () => { activityPage.value = 1 })
|
||||
|
||||
function startEditTask(task: { id: string; title: string; priority: string; projectId?: string | null }) {
|
||||
editingTaskId.value = task.id
|
||||
editTaskTitle.value = task.title
|
||||
editTaskPriority.value = task.priority
|
||||
editTaskProjectId.value = task.projectId ?? null
|
||||
}
|
||||
|
||||
async function saveEditTask(id: string) {
|
||||
try {
|
||||
await store.updateTask(id, {
|
||||
title: editTaskTitle.value.trim() || undefined,
|
||||
priority: editTaskPriority.value || undefined,
|
||||
projectId: editTaskProjectId.value || undefined,
|
||||
})
|
||||
editingTaskId.value = null
|
||||
} catch (e) {
|
||||
console.error('Failed to update task', e)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEditTask() {
|
||||
editingTaskId.value = null
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const value = message.value.trim()
|
||||
if (!value || chatPending.value) return
|
||||
chatMessages.value.push({ role: 'owner', content: value })
|
||||
message.value = ''
|
||||
chatPending.value = true
|
||||
try {
|
||||
const response = await apiFetch('/api/v1/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: value, conversationId: conversationId.value, agentId: 'iris' }),
|
||||
})
|
||||
const payload = await response.json()
|
||||
if (!response.ok) throw new Error(payload.detail ?? 'Iris is currently unavailable.')
|
||||
conversationId.value = payload.conversationId
|
||||
localStorage.setItem('nexus-conversation-id', payload.conversationId)
|
||||
chatMessages.value.push({ role: 'iris', content: payload.content })
|
||||
} catch (error) {
|
||||
chatMessages.value.push({ role: 'error', content: error instanceof Error ? error.message : 'Iris is currently unavailable.' })
|
||||
} finally {
|
||||
chatPending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form v-if="view === 'Projects'" class="quick-create" @submit.prevent="newProject.trim() && (emit('createProject', newProject.trim()), newProject = '')"><input v-model="newProject" placeholder="New project name" /><button>Create project</button></form>
|
||||
<div v-if="view === 'Projects'" class="module-grid">
|
||||
<article v-for="project in snapshot.projects" :key="project.id" class="module-card project-card" @click="$router.push(`/projects/${project.id}`)">
|
||||
<div class="module-card-head"><span class="project-letter">{{ project.name[0] }}</span><span class="badge positive">{{ project.status }}</span></div>
|
||||
<h3>{{ project.name }}</h3><p>Operational workspace managed through Nexus.</p>
|
||||
<div class="progress"><i :style="{ width: `${project.progress}%` }"></i></div>
|
||||
<footer><span>Progress</span><strong>{{ project.progress }}%</strong></footer>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<form v-else-if="view === 'Task Board'" class="quick-create" @submit.prevent="newTask.trim() && (emit('createTask', newTask.trim(), 'Normal'), newTask = '')"><input v-model="newTask" placeholder="New task title" /><button>Create task</button></form>
|
||||
<div v-if="view === 'Task Board'" class="kanban">
|
||||
<section v-for="column in columns" :key="column.name" class="kanban-column">
|
||||
<header><span>{{ column.name }}</span><b>{{ column.items.length }}</b></header>
|
||||
<article v-for="task in column.items" :key="task.id" class="task-card">
|
||||
<template v-if="editingTaskId === task.id">
|
||||
<input v-model="editTaskTitle" class="task-edit-input" placeholder="Task title" maxlength="240" />
|
||||
<div class="task-edit-row">
|
||||
<select v-model="editTaskPriority">
|
||||
<option value="Critical">Critical</option>
|
||||
<option value="High">High</option>
|
||||
<option value="Normal">Normal</option>
|
||||
<option value="Low">Low</option>
|
||||
</select>
|
||||
<select v-model="editTaskProjectId">
|
||||
<option :value="null">No project</option>
|
||||
<option v-for="p in snapshot.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="task-edit-actions">
|
||||
<button class="task-edit-save" @click="saveEditTask(task.id)"><Save :size="13" /> Save</button>
|
||||
<button class="task-edit-cancel" @click="cancelEditTask"><X :size="13" /> Cancel</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="task-card-head">
|
||||
<span :class="['priority', task.priority.toLowerCase()]">{{ task.priority }}</span>
|
||||
<div class="task-card-actions">
|
||||
<template v-if="task.state === 'In progress'">
|
||||
<button
|
||||
class="task-approve-btn"
|
||||
title="Approve"
|
||||
:disabled="approvingTaskId === task.id"
|
||||
@click="handleApproveTask(task.id)"
|
||||
><CheckCircle2 :size="13" /></button>
|
||||
<button
|
||||
class="task-reject-btn"
|
||||
title="Reject"
|
||||
:disabled="approvingTaskId === task.id"
|
||||
@click="handleRejectTask(task.id)"
|
||||
><X :size="13" /></button>
|
||||
</template>
|
||||
<button class="task-edit-btn" @click="startEditTask(task)" title="Edit task"><Edit2 :size="12" /></button>
|
||||
<button
|
||||
v-if="task.state === 'Done' || task.state === 'Backlog'"
|
||||
class="task-delete-btn"
|
||||
title="Delete task"
|
||||
@click="deletingTaskId = task.id; deleteError = ''"
|
||||
><Trash2 :size="12" /></button>
|
||||
</div>
|
||||
</div>
|
||||
<h3>{{ task.title }}</h3>
|
||||
<select :value="task.state" @change="emit('updateTaskState', task.id, ($event.target as HTMLSelectElement).value)">
|
||||
<option v-for="state in TASK_STATES" :key="state" :value="state">{{ state }}</option>
|
||||
</select>
|
||||
<footer><Clock3 :size="13" /> {{ new Date(task.updatedAt).toLocaleString() }}</footer>
|
||||
</template>
|
||||
</article>
|
||||
<div v-if="!column.items.length" class="empty-state">No tasks</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-else-if="view === 'Agents'" class="module-grid">
|
||||
<div v-if="agentsLoading" class="loading-agents">Loading agents…</div>
|
||||
<article v-for="agent in agents" :key="agent.id" class="module-card agent-card">
|
||||
<div class="agent-avatar" :class="agent.role === 'orchestrator' ? 'violet' : ''">
|
||||
<Bot v-if="agent.role === 'orchestrator'" :size="22" />
|
||||
<Zap v-else :size="22" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="kicker">{{ agent.role.toUpperCase() }}</span>
|
||||
<h3>{{ agent.name }}</h3>
|
||||
<p>{{ agent.description || agent.model }}</p>
|
||||
</div>
|
||||
<div class="agent-status-group">
|
||||
<span v-if="agent.model" class="agent-model-tag">{{ agent.model.replace(/^[^/]*\//, '') }}</span>
|
||||
<span :class="['badge', agent.status === 'Online' ? 'positive' : agent.status === 'Degraded' ? 'warning' : 'negative']">{{ agent.status }}</span>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="!agentsLoading && !agents.length" class="empty-state">No agents available</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="view === 'Models'" class="module-list panel">
|
||||
<div v-for="model in routing" :key="model.model" class="model-detail">
|
||||
<div class="route-rank">0{{ model.priority }}</div><div><span class="kicker">{{ model.purpose }}</span><h3>{{ model.model }}</h3><p>{{ model.provider }} · {{ model.detail }}</p></div><span :class="['badge', model.status === 'Online' ? 'positive' : 'warning']">{{ model.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="view === 'Activity'" class="activity-panel panel">
|
||||
<div class="activity-filters">
|
||||
<div class="filter-group">
|
||||
<label>Type</label>
|
||||
<select v-model="activityTypeFilter">
|
||||
<option value="">All types</option>
|
||||
<option v-for="type in availableTypes" :key="type" :value="type">{{ type }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label>Sort</label>
|
||||
<select v-model="activitySort">
|
||||
<option value="newest">Newest first</option>
|
||||
<option value="oldest">Oldest first</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline">
|
||||
<article v-for="event in filteredActivity" :key="event.message + event.at">
|
||||
<div :class="['timeline-icon', event.type]">
|
||||
<CheckCircle2 v-if="event.type !== 'security'" :size="15" />
|
||||
<ShieldAlert v-else :size="15" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="kicker">{{ event.type }}</span>
|
||||
<h3>{{ event.message }}</h3>
|
||||
<p>{{ new Date(event.at).toLocaleString() }}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="activityTotalPages > 1" class="activity-pagination">
|
||||
<button :disabled="activityPage <= 1" @click="activityPage--"><ChevronLeft :size="14" /></button>
|
||||
<span>{{ activityPage }} / {{ activityTotalPages }}</span>
|
||||
<button :disabled="activityPage >= activityTotalPages" @click="activityPage++"><ChevronRight :size="14" /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="view === 'Settings'" class="settings-redirect">
|
||||
<p>Use the <router-link to="/settings">full Settings page</router-link> for profile management and password changes.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="view === 'Mobile Chat'" class="chat-shell panel">
|
||||
<header><div class="agent-avatar"><MessageSquareText :size="20" /></div><div><h3>Iris Mobile</h3><p>Secure owner operations channel</p></div><span class="badge warning">Preview</span></header>
|
||||
<div class="messages"><div class="message iris"><strong>Iris</strong><p>Nexus is online. Messages are routed through the OpenClaw runtime.</p></div><div v-for="(item, index) in chatMessages" :key="index" :class="['message', item.role]"><strong>{{ item.role === 'owner' ? 'Owner' : item.role === 'iris' ? 'Iris' : 'Runtime' }}</strong><p>{{ item.content }}</p></div><div v-if="chatPending" class="message iris pending"><strong>Iris</strong><p>Working...</p></div></div>
|
||||
<form @submit.prevent="sendMessage"><input v-model="message" :disabled="chatPending" placeholder="Ask for status or create a task..." /><button :disabled="chatPending"><Send :size="15" /></button></form>
|
||||
</div>
|
||||
|
||||
<!-- Task deletion confirmation dialog -->
|
||||
<Teleport to="body">
|
||||
<div v-if="deletingTaskId" class="delete-overlay" @click.self="cancelDeleteTask">
|
||||
<div class="delete-dialog">
|
||||
<h3>Delete Task?</h3>
|
||||
<p>This action cannot be undone. The task will be permanently removed.</p>
|
||||
<p v-if="deleteError" class="delete-error">{{ deleteError }}</p>
|
||||
<div class="delete-actions">
|
||||
<button class="delete-cancel" @click="cancelDeleteTask">Cancel</button>
|
||||
<button class="delete-confirm" @click="confirmDeleteTask(deletingTaskId)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.task-card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.task-card-actions {
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
align-items: center;
|
||||
}
|
||||
.task-edit-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.15rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.task-card:hover .task-edit-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
.task-edit-btn:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.task-delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.15rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
.task-card:hover .task-delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
.task-delete-btn:hover {
|
||||
color: var(--danger, #e74c3c);
|
||||
}
|
||||
.task-edit-input {
|
||||
width: 100%;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.task-edit-row {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.task-edit-row select {
|
||||
flex: 1;
|
||||
padding: 0.25rem 0.4rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.task-edit-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.task-edit-save {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.task-edit-cancel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--surface-raised);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.activity-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.activity-filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.filter-group label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.filter-group select {
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.activity-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.activity-pagination button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.activity-pagination button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.activity-pagination span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.project-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.project-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
.settings-redirect {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.settings-redirect a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.settings-redirect a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Task deletion confirmation */
|
||||
.delete-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.delete-dialog {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
max-width: 380px;
|
||||
width: 90%;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
|
||||
}
|
||||
.delete-dialog h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.delete-dialog p {
|
||||
margin: 0 0 1rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.delete-error {
|
||||
color: var(--danger, #e74c3c) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
.delete-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.delete-cancel {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.delete-confirm {
|
||||
padding: 0.45rem 1rem;
|
||||
background: var(--danger, #e74c3c);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.delete-confirm:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Agent card enhancements */
|
||||
.agent-status-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.agent-model-tag {
|
||||
font-size: 0.7rem;
|
||||
background: var(--surface-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.1rem 0.35rem;
|
||||
color: var(--text-muted);
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.loading-agents {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Task approve/reject buttons */
|
||||
.task-approve-btn,
|
||||
.task-reject-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0.15rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
.task-card:hover .task-approve-btn,
|
||||
.task-card:hover .task-reject-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
.task-approve-btn {
|
||||
color: var(--success, #27ae60);
|
||||
}
|
||||
.task-approve-btn:hover {
|
||||
color: var(--success, #27ae60);
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
.task-reject-btn {
|
||||
color: var(--warning, #f39c12);
|
||||
}
|
||||
.task-reject-btn:hover {
|
||||
color: var(--danger, #e74c3c);
|
||||
}
|
||||
.task-approve-btn:disabled,
|
||||
.task-reject-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,206 @@
|
||||
<script setup lang="ts">
|
||||
import { Loader2, Save, Check, AlertCircle } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
fileName: string | null
|
||||
fileSize: string
|
||||
fileModified: string
|
||||
content: string
|
||||
dirty: boolean
|
||||
saving: boolean
|
||||
saveStatus: 'idle' | 'saved' | 'error'
|
||||
saveMessage: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
updateContent: [value: string]
|
||||
save: []
|
||||
}>()
|
||||
|
||||
function onInput(event: Event) {
|
||||
const textarea = event.target as HTMLTextAreaElement
|
||||
// Pass content change up, parent handles dirty detection
|
||||
;(event.target as HTMLTextAreaElement).dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-panel">
|
||||
<!-- File info header -->
|
||||
<div class="editor-header">
|
||||
<div class="editor-file-info">
|
||||
<span class="editor-filename">{{ fileName || '—' }}</span>
|
||||
<span v-if="fileName" class="editor-file-meta">
|
||||
{{ fileSize }}
|
||||
<span class="meta-sep">·</span>
|
||||
{{ fileModified }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Save button & status -->
|
||||
<div class="editor-actions">
|
||||
<span v-if="saveStatus === 'saved'" class="save-indicator success">
|
||||
<Check :size="14" />
|
||||
{{ saveMessage }}
|
||||
</span>
|
||||
<span v-if="saveStatus === 'error'" class="save-indicator error">
|
||||
<AlertCircle :size="14" />
|
||||
{{ saveMessage }}
|
||||
</span>
|
||||
<button
|
||||
class="save-btn"
|
||||
:class="{ dirty, saving }"
|
||||
:disabled="!dirty || saving"
|
||||
@click="$emit('save')"
|
||||
>
|
||||
<Loader2 v-if="saving" :size="14" class="spin" />
|
||||
<Save v-else :size="14" />
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text editor -->
|
||||
<textarea
|
||||
class="config-editor"
|
||||
:value="content"
|
||||
@input="$emit('updateContent', ($event.target as HTMLTextAreaElement).value)"
|
||||
spellcheck="false"
|
||||
wrap="off"
|
||||
></textarea>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-panel {
|
||||
border: 1px solid var(--line, #1e2030);
|
||||
border-top: none;
|
||||
border-radius: 0 0 10px 10px;
|
||||
overflow: hidden;
|
||||
background: var(--panel, #13141f);
|
||||
}
|
||||
|
||||
.editor-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: rgba(255,255,255,.02);
|
||||
border-bottom: 1px solid var(--line, #1e2030);
|
||||
gap: 12px;
|
||||
}
|
||||
.editor-file-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
.editor-filename {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: #d0d4dd;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.editor-file-meta {
|
||||
font-size: 10px;
|
||||
color: #6b7385;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.meta-sep {
|
||||
margin: 0 4px;
|
||||
color: #3d4152;
|
||||
}
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.save-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.save-indicator.success {
|
||||
color: #51d49a;
|
||||
}
|
||||
.save-indicator.error {
|
||||
color: #e16e75;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--line, #1e2030);
|
||||
border-radius: 7px;
|
||||
background: rgba(139,124,246,.08);
|
||||
color: #8b7cf6;
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
|
||||
font-family: inherit;
|
||||
line-height: 1;
|
||||
}
|
||||
.save-btn:hover:not(:disabled) {
|
||||
background: rgba(139,124,246,.14);
|
||||
border-color: #443d7c;
|
||||
}
|
||||
.save-btn.dirty {
|
||||
background: rgba(139,124,246,.18);
|
||||
border-color: #5c4ed6;
|
||||
color: #a99cff;
|
||||
}
|
||||
.save-btn.saving {
|
||||
opacity: 0.7;
|
||||
cursor: wait;
|
||||
}
|
||||
.save-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.config-editor {
|
||||
width: 100%;
|
||||
min-height: 400px;
|
||||
padding: 16px;
|
||||
border: none;
|
||||
background: #0d0e17;
|
||||
color: #c8cbe0;
|
||||
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
tab-size: 2;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.config-editor:focus {
|
||||
background: #0f101b;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.editor-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
.editor-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
tabs: string[]
|
||||
activeTab: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
switchTab: [index: number]
|
||||
}>()
|
||||
|
||||
function tabLabel(tab: string): string {
|
||||
return tab.replace('.md', '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="config-tabs">
|
||||
<button
|
||||
v-for="(tab, idx) in tabs"
|
||||
:key="tab"
|
||||
class="config-tab"
|
||||
:class="{ active: activeTab === idx }"
|
||||
@click="$emit('switchTab', idx)"
|
||||
>
|
||||
{{ tabLabel(tab) }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.config-tabs {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
background: var(--line, #1e2030);
|
||||
border-radius: 10px 10px 0 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line, #1e2030);
|
||||
border-bottom: none;
|
||||
}
|
||||
.config-tab {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
background: var(--panel, #13141f);
|
||||
border: none;
|
||||
color: #6b7385;
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
letter-spacing: 0.04em;
|
||||
font-family: inherit;
|
||||
}
|
||||
.config-tab:hover {
|
||||
background: rgba(139,124,246,.06);
|
||||
color: #a0a8b8;
|
||||
}
|
||||
.config-tab.active {
|
||||
background: rgba(139,124,246,.1);
|
||||
color: #c8cbe0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.config-tabs {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.config-tab {
|
||||
flex: 1 0 auto;
|
||||
min-width: 80px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Clock } from '@lucide/vue'
|
||||
|
||||
type InitiativeStatus = 'healthy' | 'attention' | 'blocked' | 'paused' | 'completed'
|
||||
|
||||
interface Initiative {
|
||||
title: string
|
||||
progress: number
|
||||
openTasks: number
|
||||
blockers: number
|
||||
status: InitiativeStatus
|
||||
lastActivity: string
|
||||
}
|
||||
|
||||
const initiatives = ref<Initiative[]>([
|
||||
{ title: 'OpenClaw Companion', progress: 55, openTasks: 7, blockers: 2, status: 'healthy', lastActivity: 'vor 8 Minuten' },
|
||||
{ title: '2D Idle Game', progress: 42, openTasks: 4, blockers: 0, status: 'healthy', lastActivity: 'vor 2 Stunden' },
|
||||
{ title: 'Deutsch B2', progress: 73, openTasks: 3, blockers: 0, status: 'attention', lastActivity: 'vor 1 Stunde' },
|
||||
{ title: 'Nexus Dashboard', progress: 60, openTasks: 3, blockers: 0, status: 'healthy', lastActivity: 'vor 5 Minuten' },
|
||||
])
|
||||
|
||||
const statusMeta: Record<InitiativeStatus, { label: string; color: string; bg: string }> = {
|
||||
healthy: { label: 'Healthy', color: '#22c55e', bg: 'rgba(34,197,94,0.1)' },
|
||||
attention: { label: 'Needs Attention', color: '#eab308', bg: 'rgba(234,179,8,0.1)' },
|
||||
blocked: { label: 'Blocked', color: '#ef4444', bg: 'rgba(239,68,68,0.1)' },
|
||||
paused: { label: 'Paused', color: '#6b7280', bg: 'rgba(107,114,128,0.1)' },
|
||||
completed: { label: 'Completed', color: '#3b82f6', bg: 'rgba(59,130,246,0.1)' },
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="initiatives-section">
|
||||
<h2>Active Initiatives</h2>
|
||||
<div class="initiatives-grid">
|
||||
<div
|
||||
v-for="(init, idx) in initiatives"
|
||||
:key="idx"
|
||||
:class="['initiative-card', 'status-' + init.status]"
|
||||
>
|
||||
<div class="init-head">
|
||||
<h3>{{ init.title }}</h3>
|
||||
<span
|
||||
class="status-badge"
|
||||
:style="{
|
||||
color: statusMeta[init.status].color,
|
||||
background: statusMeta[init.status].bg,
|
||||
}"
|
||||
>
|
||||
{{ statusMeta[init.status].label }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
:style="{ width: init.progress + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="progress-label">{{ init.progress }}%</div>
|
||||
<div class="init-stats">
|
||||
<span>{{ init.openTasks }} offene Aufgaben</span>
|
||||
<span v-if="init.blockers">· {{ init.blockers }} Blocker</span>
|
||||
</div>
|
||||
<div class="init-meta">
|
||||
<Clock :size="11" />
|
||||
<span>Letzte Aktivität {{ init.lastActivity }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.initiatives-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 18px;
|
||||
background: rgba(22, 27, 34, 0.8);
|
||||
border: 1px solid rgba(139, 124, 246, 0.12);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.initiatives-section:hover {
|
||||
border-color: rgba(139, 124, 246, 0.18);
|
||||
}
|
||||
.initiatives-section h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.initiatives-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.initiative-card {
|
||||
background: rgba(13, 17, 23, 0.5);
|
||||
border: 1px solid rgba(139, 124, 246, 0.08);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.initiative-card:hover {
|
||||
border-color: rgba(139, 124, 246, 0.2);
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
||||
}
|
||||
.init-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
.init-head h3 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.status-badge {
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
padding: 2px 7px;
|
||||
border-radius: 12px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.progress-bar {
|
||||
height: 4px;
|
||||
background: rgba(139, 124, 246, 0.1);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, #a78bfa, #8b5cf6);
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.initiative-card.status-attention .progress-fill {
|
||||
background: linear-gradient(90deg, #eab308, #f59e0b);
|
||||
}
|
||||
.initiative-card.status-blocked .progress-fill {
|
||||
background: linear-gradient(90deg, #ef4444, #dc2626);
|
||||
}
|
||||
.progress-label {
|
||||
font-size: 10px;
|
||||
color: #6b7385;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.init-stats {
|
||||
font-size: 9px;
|
||||
color: #6b7385;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.init-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 9px;
|
||||
color: #6b7385;
|
||||
}
|
||||
.init-meta svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.initiatives-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.initiatives-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,188 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { CheckCircle2, Circle, AlertTriangle } from '@lucide/vue'
|
||||
|
||||
interface AgendaItem {
|
||||
text: string
|
||||
time?: string
|
||||
done?: boolean
|
||||
overdue?: boolean
|
||||
}
|
||||
|
||||
const agendaToday = ref<AgendaItem[]>([
|
||||
{ text: 'Teammeeting', time: '14:00' },
|
||||
{ text: 'Deutsch lernen', time: '18:00' },
|
||||
{ text: 'Steuerunterlagen prüfen' },
|
||||
{ text: 'Dungeon-Balance abschließen' },
|
||||
])
|
||||
|
||||
const agendaTomorrow = ref<AgendaItem[]>([
|
||||
{ text: 'GitHub Issue #23' },
|
||||
{ text: 'Backup überprüfen' },
|
||||
])
|
||||
|
||||
const agendaOverdue = ref<AgendaItem[]>([
|
||||
{ text: 'Hangfire konfigurieren', overdue: true },
|
||||
])
|
||||
|
||||
function toggleAgendaItem(item: AgendaItem) {
|
||||
item.done = !item.done
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="agenda-panel">
|
||||
<h2>Agenda</h2>
|
||||
|
||||
<div class="agenda-section">
|
||||
<h3>Heute</h3>
|
||||
<div
|
||||
v-for="(item, idx) in agendaToday"
|
||||
:key="'today-' + idx"
|
||||
:class="['agenda-item', { done: item.done }]"
|
||||
@click="toggleAgendaItem(item)"
|
||||
>
|
||||
<button class="agenda-check">
|
||||
<CheckCircle2 v-if="item.done" :size="14" class="checked" />
|
||||
<Circle v-else :size="14" />
|
||||
</button>
|
||||
<span class="agenda-text">{{ item.text }}</span>
|
||||
<span v-if="item.time" class="agenda-time">{{ item.time }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agenda-section">
|
||||
<h3>Morgen</h3>
|
||||
<div
|
||||
v-for="(item, idx) in agendaTomorrow"
|
||||
:key="'tomorrow-' + idx"
|
||||
:class="['agenda-item', { done: item.done }]"
|
||||
@click="toggleAgendaItem(item)"
|
||||
>
|
||||
<button class="agenda-check">
|
||||
<CheckCircle2 v-if="item.done" :size="14" class="checked" />
|
||||
<Circle v-else :size="14" />
|
||||
</button>
|
||||
<span class="agenda-text">{{ item.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="agenda-section">
|
||||
<h3 class="overdue-heading">
|
||||
<AlertTriangle :size="12" />
|
||||
Überfällig
|
||||
</h3>
|
||||
<div
|
||||
v-for="(item, idx) in agendaOverdue"
|
||||
:key="'overdue-' + idx"
|
||||
class="agenda-item overdue"
|
||||
>
|
||||
<button class="agenda-check">
|
||||
<AlertTriangle :size="14" class="overdue-icon" />
|
||||
</button>
|
||||
<span class="agenda-text">{{ item.text }}</span>
|
||||
<span class="agenda-sub">seit 2 Tagen</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agenda-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 18px;
|
||||
background: rgba(22, 27, 34, 0.8);
|
||||
border: 1px solid rgba(139, 124, 246, 0.12);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.agenda-panel:hover {
|
||||
border-color: rgba(139, 124, 246, 0.18);
|
||||
}
|
||||
.agenda-panel h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.agenda-section h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #6b7385;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 4px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid rgba(139, 124, 246, 0.06);
|
||||
}
|
||||
.overdue-heading {
|
||||
color: #ef4444 !important;
|
||||
border-bottom-color: rgba(239, 68, 68, 0.15) !important;
|
||||
}
|
||||
.agenda-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 5px 6px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.agenda-item:hover {
|
||||
background: rgba(139, 124, 246, 0.04);
|
||||
}
|
||||
.agenda-item.done {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.agenda-item.done .agenda-text {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.agenda-check {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6b7385;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.agenda-check .checked {
|
||||
color: #22c55e;
|
||||
}
|
||||
.overdue .overdue-icon {
|
||||
color: #ef4444;
|
||||
}
|
||||
.agenda-text {
|
||||
flex: 1;
|
||||
font-size: 10.5px;
|
||||
color: #7e8799;
|
||||
}
|
||||
.agenda-time {
|
||||
font-size: 9px;
|
||||
color: #6b7385;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.agenda-sub {
|
||||
font-size: 8px;
|
||||
color: #ef4444;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.agenda-item.overdue {
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.agenda-panel {
|
||||
order: 3;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,315 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { Bot, Sparkles, MessageSquareText, ListTodo, Zap, FileText, Send, Lightbulb } from '@lucide/vue'
|
||||
import { useTime } from '../../composables/useTime'
|
||||
|
||||
const { greeting } = useTime()
|
||||
|
||||
const chatInput = ref('')
|
||||
function sendChat() {
|
||||
if (!chatInput.value.trim()) return
|
||||
alert(`[Iris] Received: "${chatInput.value}"`)
|
||||
chatInput.value = ''
|
||||
}
|
||||
|
||||
const meters = {
|
||||
openTasks: 12,
|
||||
blocked: 3,
|
||||
overdue: 2,
|
||||
todayAppointments: 4,
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="iris-panel">
|
||||
<div class="iris-profile">
|
||||
<div class="iris-avatar">
|
||||
<Bot :size="32" />
|
||||
</div>
|
||||
<div class="iris-name-block">
|
||||
<h2>Iris</h2>
|
||||
<span class="iris-role">Chief of Staff</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="iris-greeting">{{ greeting }} Bao.</p>
|
||||
<p class="iris-status">Du hast heute <strong>4 wichtige Punkte.</strong></p>
|
||||
|
||||
<div class="meters">
|
||||
<div class="meter-item">
|
||||
<span class="meter-value">{{ meters.openTasks }}</span>
|
||||
<span class="meter-label">Offene Aufgaben</span>
|
||||
</div>
|
||||
<div class="meter-item">
|
||||
<span class="meter-value meter-blocked">{{ meters.blocked }}</span>
|
||||
<span class="meter-label">Blockiert</span>
|
||||
</div>
|
||||
<div class="meter-item">
|
||||
<span class="meter-value meter-overdue">{{ meters.overdue }}</span>
|
||||
<span class="meter-label">Überfällig</span>
|
||||
</div>
|
||||
<div class="meter-item">
|
||||
<span class="meter-value meter-today">{{ meters.todayAppointments }}</span>
|
||||
<span class="meter-label">Heute</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="suggestions">
|
||||
<h3><Sparkles :size="14" /> Vorschläge</h3>
|
||||
<div class="suggestion-card">
|
||||
<Lightbulb :size="14" class="bulb" />
|
||||
<span>Du solltest zuerst das Dungeon-System abschließen.</span>
|
||||
</div>
|
||||
<div class="suggestion-card">
|
||||
<Lightbulb :size="14" class="bulb" />
|
||||
<span>Die Dokumentation wurde seit 3 Tagen nicht aktualisiert.</span>
|
||||
</div>
|
||||
<div class="suggestion-card">
|
||||
<Lightbulb :size="14" class="bulb" />
|
||||
<span>Das Projekt OpenClaw benötigt Aufmerksamkeit.</span>
|
||||
</div>
|
||||
<div class="suggestion-card">
|
||||
<Lightbulb :size="14" class="bulb" />
|
||||
<span>Deine wöchentliche Zusammenfassung ist bereit.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="quick-actions">
|
||||
<button class="qa-btn">
|
||||
<MessageSquareText :size="14" /> Chat öffnen
|
||||
</button>
|
||||
<button class="qa-btn">
|
||||
<ListTodo :size="14" /> Tagesplanung
|
||||
</button>
|
||||
<button class="qa-btn">
|
||||
<Zap :size="14" /> Prioritäten setzen
|
||||
</button>
|
||||
<button class="qa-btn">
|
||||
<FileText :size="14" /> Zusammenfassung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="chat-box">
|
||||
<div class="chat-input-row">
|
||||
<input
|
||||
v-model="chatInput"
|
||||
type="text"
|
||||
placeholder="Frag Iris etwas..."
|
||||
@keyup.enter="sendChat"
|
||||
/>
|
||||
<button class="chat-send" @click="sendChat">
|
||||
<Send :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.iris-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
background: rgba(22, 27, 34, 0.8);
|
||||
border: 1px solid rgba(139, 124, 246, 0.12);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.iris-panel:hover {
|
||||
border-color: rgba(139, 124, 246, 0.18);
|
||||
}
|
||||
|
||||
.iris-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.iris-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
background: rgba(167, 139, 250, 0.15);
|
||||
color: #a78bfa;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.iris-name-block h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.iris-role {
|
||||
font-size: 10px;
|
||||
color: #a78bfa;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.iris-greeting {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.iris-status {
|
||||
font-size: 11px;
|
||||
color: #7e8799;
|
||||
margin: 0;
|
||||
}
|
||||
.iris-status strong {
|
||||
color: #e8eaf0;
|
||||
}
|
||||
|
||||
/* Meters */
|
||||
.meters {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
.meter-item {
|
||||
background: rgba(139, 124, 246, 0.06);
|
||||
border: 1px solid rgba(139, 124, 246, 0.08);
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.meter-item:hover {
|
||||
border-color: rgba(139, 124, 246, 0.18);
|
||||
background: rgba(139, 124, 246, 0.1);
|
||||
}
|
||||
.meter-value {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.meter-blocked { color: #eab308; }
|
||||
.meter-overdue { color: #ef4444; }
|
||||
.meter-today { color: #3b82f6; }
|
||||
.meter-label {
|
||||
display: block;
|
||||
font-size: 8px;
|
||||
color: #6b7385;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Suggestions */
|
||||
.suggestions h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #a78bfa;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.suggestion-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 7px;
|
||||
padding: 7px 8px;
|
||||
margin-bottom: 3px;
|
||||
border-radius: 8px;
|
||||
cursor: default;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.suggestion-card:hover {
|
||||
background: rgba(139, 124, 246, 0.08);
|
||||
}
|
||||
.suggestion-card .bulb {
|
||||
color: #eab308;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.suggestion-card span {
|
||||
font-size: 10.5px;
|
||||
line-height: 1.4;
|
||||
color: #7e8799;
|
||||
}
|
||||
|
||||
/* Quick Actions */
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.qa-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(139, 124, 246, 0.1);
|
||||
border-radius: 8px;
|
||||
background: rgba(139, 124, 246, 0.04);
|
||||
color: #7e8799;
|
||||
font-size: 10.5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.qa-btn:hover {
|
||||
background: rgba(139, 124, 246, 0.12);
|
||||
border-color: rgba(139, 124, 246, 0.2);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
|
||||
/* Chat Box */
|
||||
.chat-box {
|
||||
margin-top: auto;
|
||||
}
|
||||
.chat-input-row {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
.chat-input-row input {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgba(139, 124, 246, 0.12);
|
||||
border-radius: 8px;
|
||||
background: rgba(13, 17, 23, 0.6);
|
||||
color: #e8eaf0;
|
||||
font-size: 10.5px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.chat-input-row input:focus {
|
||||
border-color: #a78bfa;
|
||||
}
|
||||
.chat-input-row input::placeholder {
|
||||
color: #6b7385;
|
||||
}
|
||||
.chat-send {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #a78bfa;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.chat-send:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.iris-panel {
|
||||
order: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,210 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
type FeedStatus = 'running' | 'done' | 'waiting' | 'error' | 'info'
|
||||
|
||||
interface FeedItem {
|
||||
time: string
|
||||
status: FeedStatus
|
||||
text: string
|
||||
label: string
|
||||
date: 'today' | 'yesterday' | 'week'
|
||||
}
|
||||
|
||||
const allFeed: FeedItem[] = [
|
||||
{ time: '09:17', status: 'running', text: 'OpenClaw analysiert Memory-Datenbank.', label: 'Memory', date: 'today' },
|
||||
{ time: '09:19', status: 'done', text: 'Repository Refactoring abgeschlossen.', label: 'Coding', date: 'today' },
|
||||
{ time: '09:21', status: 'done', text: '3 neue Erinnerungen gespeichert.', label: 'Memory', date: 'today' },
|
||||
{ time: '09:25', status: 'done', text: 'Dungeon-Service erfolgreich kompiliert.', label: 'Coding', date: 'today' },
|
||||
{ time: '09:28', status: 'error', text: 'Build fehlgeschlagen — NullReferenceException in EnemyFactory.', label: 'Coding', date: 'today' },
|
||||
{ time: '09:31', status: 'waiting', text: 'Iris hat "Steuerunterlagen" auf Freitag verschoben.', label: 'Personal', date: 'today' },
|
||||
{ time: '10:02', status: 'running', text: 'Programmer arbeitet an TeamView-Redesign.', label: 'Coding', date: 'today' },
|
||||
{ time: '10:15', status: 'done', text: 'AgentDetailView deployed.', label: 'System', date: 'today' },
|
||||
{ time: '10:22', status: 'running', text: 'Architekt prüft Compose-Konfiguration.', label: 'System', date: 'today' },
|
||||
{ time: '10:45', status: 'done', text: 'Reviewer: Code-Review abgeschlossen, keine Findings.', label: 'Agenten', date: 'today' },
|
||||
{ time: '11:00', status: 'running', text: 'Researcher analysiert API-Dokumentation.', label: 'Research', date: 'today' },
|
||||
{ time: '11:30', status: 'waiting', text: 'Executor wartet auf Deployment-Freigabe.', label: 'System', date: 'today' },
|
||||
{ time: '15:22', status: 'done', text: 'Nexus Dashboard Migration geplant.', label: 'Coding', date: 'yesterday' },
|
||||
{ time: '16:05', status: 'done', text: 'Docker Compose Optimierung abgeschlossen.', label: 'System', date: 'yesterday' },
|
||||
]
|
||||
|
||||
const feedFilter = ref<string | null>(null)
|
||||
const filterLabels = ['Alle', 'Coding', 'Research', 'Personal', 'Memory', 'Agenten', 'System']
|
||||
|
||||
const filteredFeed = computed(() => {
|
||||
if (!feedFilter.value || feedFilter.value === 'Alle') return allFeed
|
||||
return allFeed.filter(item => item.label === feedFilter.value)
|
||||
})
|
||||
|
||||
const feedGroups = computed(() => {
|
||||
const groups: { date: string; items: FeedItem[] }[] = []
|
||||
const dates = ['today', 'yesterday', 'week'] as const
|
||||
for (const d of dates) {
|
||||
const items = filteredFeed.value.filter(i => i.date === d)
|
||||
if (items.length) {
|
||||
groups.push({
|
||||
date: d === 'today' ? 'Heute' : d === 'yesterday' ? 'Gestern' : 'Diese Woche',
|
||||
items,
|
||||
})
|
||||
}
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
const statusColor = (s: FeedStatus): string => {
|
||||
const m: Record<FeedStatus, string> = {
|
||||
running: '#3b82f6',
|
||||
done: '#22c55e',
|
||||
waiting: '#eab308',
|
||||
error: '#ef4444',
|
||||
info: '#6b7385',
|
||||
}
|
||||
return m[s]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="feed-panel">
|
||||
<h2 class="feed-title">Operations Feed</h2>
|
||||
|
||||
<div class="filter-pills">
|
||||
<button
|
||||
v-for="label in filterLabels"
|
||||
:key="label"
|
||||
:class="{ active: feedFilter === label || (!feedFilter && label === 'Alle') }"
|
||||
@click="feedFilter = label === 'Alle' ? null : label"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="feed-list">
|
||||
<template v-for="group in feedGroups" :key="group.date">
|
||||
<div class="feed-date-heading">{{ group.date }}</div>
|
||||
<div
|
||||
v-for="(item, idx) in group.items"
|
||||
:key="idx"
|
||||
class="feed-item"
|
||||
>
|
||||
<span class="feed-time">{{ item.time }}</span>
|
||||
<span class="feed-dot" :style="{ background: statusColor(item.status) }"></span>
|
||||
<span class="feed-text">{{ item.text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.feed-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 420px;
|
||||
padding: 18px;
|
||||
background: rgba(22, 27, 34, 0.8);
|
||||
border: 1px solid rgba(139, 124, 246, 0.12);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.feed-panel:hover {
|
||||
border-color: rgba(139, 124, 246, 0.18);
|
||||
}
|
||||
.feed-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
|
||||
/* Filter pills */
|
||||
.filter-pills {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.filter-pills::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.filter-pills button {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid rgba(139, 124, 246, 0.08);
|
||||
border-radius: 20px;
|
||||
background: transparent;
|
||||
color: #6b7385;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.filter-pills button:hover {
|
||||
border-color: rgba(139, 124, 246, 0.25);
|
||||
color: #7e8799;
|
||||
}
|
||||
.filter-pills button.active {
|
||||
background: rgba(139, 124, 246, 0.12);
|
||||
border-color: rgba(139, 124, 246, 0.25);
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
/* Feed list */
|
||||
.feed-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.feed-date-heading {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #6b7385;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
padding: 8px 0 4px;
|
||||
}
|
||||
.feed-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 6px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.feed-item:hover {
|
||||
background: rgba(139, 124, 246, 0.04);
|
||||
}
|
||||
.feed-time {
|
||||
font-size: 9px;
|
||||
color: #6b7385;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.feed-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 0 4px currentColor;
|
||||
}
|
||||
.feed-text {
|
||||
font-size: 10.5px;
|
||||
line-height: 1.3;
|
||||
color: #7e8799;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.feed-panel {
|
||||
order: 2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const recentlyFinished = ref([
|
||||
'Docker Image gebaut',
|
||||
'Memory Compression',
|
||||
'Enemy AI verbessert',
|
||||
'Daily Backup',
|
||||
'TeamView deployt',
|
||||
'Config-Editor live',
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="finished-section">
|
||||
<h3>Recently Finished</h3>
|
||||
<div class="finished-scroll">
|
||||
<span v-for="(item, idx) in recentlyFinished" :key="idx" class="finished-chip">
|
||||
{{ item }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.finished-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.finished-section h3 {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #7e8799;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.finished-scroll {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.finished-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.finished-chip {
|
||||
flex-shrink: 0;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid rgba(139, 124, 246, 0.1);
|
||||
border-radius: 20px;
|
||||
background: rgba(139, 124, 246, 0.06);
|
||||
color: #7e8799;
|
||||
font-size: 9.5px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.finished-chip:hover {
|
||||
background: rgba(139, 124, 246, 0.12);
|
||||
border-color: rgba(139, 124, 246, 0.2);
|
||||
color: #e8eaf0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.finished-section {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { Command, Search, CircleDot, Sparkles } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
connected: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
toggleMobileNav: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<button class="mobile-menu" @click="$emit('toggleMobileNav')">
|
||||
<Command :size="19" />
|
||||
</button>
|
||||
<div class="search">
|
||||
<Search :size="16" />
|
||||
<span>Search operations</span>
|
||||
<kbd>⌘ K</kbd>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<span :class="['connection', connected ? 'live' : 'preview']">
|
||||
<CircleDot :size="13" />
|
||||
{{ connected ? 'Live' : 'Preview data' }}
|
||||
</span>
|
||||
<button class="ask"><Sparkles :size="15" /> Ask Iris</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 20px;
|
||||
border-bottom: 1px solid var(--line, #1f2330);
|
||||
background: var(--panel, #11141b);
|
||||
}
|
||||
.mobile-menu { display: none; }
|
||||
.search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--line, #1f2330);
|
||||
border-radius: 7px;
|
||||
color: var(--text-dim, #6f7889);
|
||||
font-size: 11px;
|
||||
}
|
||||
.search kbd {
|
||||
margin-left: auto;
|
||||
padding: 1px 4px;
|
||||
border: 1px solid #2a2f3d;
|
||||
border-radius: 4px;
|
||||
font-size: 8px;
|
||||
color: #4a5266;
|
||||
}
|
||||
.top-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.connection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 4px 9px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.connection.live { color: #27ae60; background: rgba(39,174,96,.1); }
|
||||
.connection.preview { color: #e67e22; background: rgba(230,126,34,.1); }
|
||||
.ask {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--accent, #7b6ef2);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.mobile-menu { display: flex; align-items: center; justify-content: center; padding: 6px; border: 1px solid var(--line, #1f2330); border-radius: 6px; background: transparent; color: var(--accent, #7b6ef2); cursor: pointer; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
Activity, Bot, Boxes, Command, FileText,
|
||||
LayoutDashboard, ListTodo, LogOut, MessageSquareText, Settings,
|
||||
Shield, SlidersHorizontal, Sparkles, Users, BookOpen,
|
||||
AlertTriangle, Calendar,
|
||||
} from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const props = defineProps<{
|
||||
activeView: string
|
||||
mobileNavOpen: boolean
|
||||
queuedTasks: number
|
||||
incidents: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [label: string]
|
||||
}>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const ownerInitials = computed(() => auth.user?.displayName.split(' ').map(part => part[0]).join('').slice(0, 2).toUpperCase() ?? 'OW')
|
||||
|
||||
const navigation = [
|
||||
{ label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ label: 'Memory', icon: FileText },
|
||||
{ label: 'Docs', icon: BookOpen },
|
||||
{ label: 'Team', icon: Users },
|
||||
{ label: 'Security', icon: Shield },
|
||||
{ label: 'Projects', icon: Boxes },
|
||||
{ label: 'Task Board', icon: ListTodo },
|
||||
{ label: 'Incidents', icon: AlertTriangle },
|
||||
{ separator: true },
|
||||
{ label: 'Calendar', icon: Calendar },
|
||||
{ label: 'Agents', icon: Bot },
|
||||
{ label: 'Models', icon: SlidersHorizontal },
|
||||
{ label: 'Activity', icon: Activity },
|
||||
{ label: 'Mobile Chat', icon: MessageSquareText },
|
||||
]
|
||||
|
||||
function onNavigate(label: string) {
|
||||
emit('navigate', label)
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
await router.replace('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside :class="['sidebar', { open: mobileNavOpen }]">
|
||||
<div class="brand">
|
||||
<div class="brand-mark"><Command :size="18" /></div>
|
||||
<div>
|
||||
<strong>NEXUS</strong>
|
||||
<span>Noveria Operations</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<template v-for="item in navigation" :key="item.label ?? 'sep'">
|
||||
<div v-if="item.separator" class="nav-separator"></div>
|
||||
<button
|
||||
v-else
|
||||
:class="{ active: activeView === item.label }"
|
||||
@click="onNavigate(item.label)"
|
||||
>
|
||||
<component :is="item.icon" :size="17" />
|
||||
<span>{{ item.label }}</span>
|
||||
<i v-if="item.label === 'Task Board'">{{ queuedTasks }}</i>
|
||||
<i v-if="item.label === 'Incidents'">{{ incidents }}</i>
|
||||
</button>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-bottom">
|
||||
<button :class="{ active: activeView === 'Settings' }" @click="onNavigate('Settings')"><Settings :size="17" /> Settings</button>
|
||||
<button class="owner" type="button" title="Sign out" @click="logout">
|
||||
<div class="avatar">{{ ownerInitials }}</div>
|
||||
<div><strong>{{ auth.user?.displayName ?? 'Owner' }}</strong><span>{{ auth.user?.role ?? 'owner' }}</span></div>
|
||||
<LogOut :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
width: 210px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel, #11141b);
|
||||
border-right: 1px solid var(--line, #1f2330);
|
||||
flex-shrink: 0;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 10px 12px;
|
||||
}
|
||||
.brand-mark {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 7px;
|
||||
background: var(--accent, #7b6ef2);
|
||||
color: #fff;
|
||||
}
|
||||
.brand div strong { display: block; font-size: 10px; letter-spacing: .08em; }
|
||||
.brand div span { font-size: 8px; color: var(--text-dim, #6f7889); }
|
||||
.nav {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding: 4px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.nav button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #9ea5b3;
|
||||
font-size: 10.5px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.nav button:hover { background: var(--accent-soft, rgba(123,110,242,.08)); color: #d8dbe3; }
|
||||
.nav button.active { background: var(--accent-soft, rgba(123,110,242,.08)); color: var(--accent, #7b6ef2); font-weight: 600; }
|
||||
.nav button i {
|
||||
margin-left: auto;
|
||||
background: var(--accent, #7b6ef2);
|
||||
color: #fff;
|
||||
font-style: normal;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
padding: 1px 5px;
|
||||
border-radius: 5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.nav-separator {
|
||||
height: 1px;
|
||||
margin: 6px 10px;
|
||||
background: var(--line, #1f2330);
|
||||
}
|
||||
.sidebar-bottom { padding: 8px 0; border-top: 1px solid var(--line, #1f2330); }
|
||||
.sidebar-bottom > button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #9ea5b3;
|
||||
font-size: 10.5px;
|
||||
cursor: pointer;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.sidebar-bottom > button:hover { background: var(--accent-soft, rgba(123,110,242,.08)); color: #d8dbe3; }
|
||||
.sidebar-bottom > button.active { background: var(--accent-soft, rgba(123,110,242,.08)); color: var(--accent, #7b6ef2); font-weight: 600; }
|
||||
.owner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.owner div strong { display: block; font-size: 9px; }
|
||||
.owner div span { font-size: 7.5px; color: var(--text-dim, #6f7889); text-transform: capitalize; }
|
||||
.owner > svg:last-child { margin-left: auto; opacity: .4; transition: opacity .15s; }
|
||||
.owner:hover > svg:last-child { opacity: 1; }
|
||||
.avatar {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 6px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent, #7b6ef2);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.sidebar { position: fixed; inset: 0; z-index: 100; transform: translateX(-100%); transition: transform .25s; }
|
||||
.sidebar.open { transform: translateX(0); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { Bot, Code2, Server, Shield, Search, Terminal } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
id: string
|
||||
name: string
|
||||
role: string
|
||||
description: string
|
||||
icon: string
|
||||
color: string
|
||||
tags: string[]
|
||||
hero?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
click: [id: string]
|
||||
}>()
|
||||
|
||||
function resolveIcon(iconName: string) {
|
||||
switch (iconName) {
|
||||
case 'bot': return Bot
|
||||
case 'code': return Code2
|
||||
case 'server': return Server
|
||||
case 'shield': return Shield
|
||||
case 'search': return Search
|
||||
case 'terminal': return Terminal
|
||||
default: return Bot
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
:class="['agent-card', { 'hero-card': hero }]"
|
||||
:style="{ '--card-color': color }"
|
||||
@click="$emit('click', id)"
|
||||
>
|
||||
<div class="card-main">
|
||||
<div class="card-icon-wrap" :style="{ background: `${color}18`, color: color }">
|
||||
<component :is="resolveIcon(icon)" :size="hero ? 20 : 18" />
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-name-row">
|
||||
<h3 class="card-name">{{ name }}</h3>
|
||||
<span class="card-role-tag" :style="{ background: `${color}18`, color: color, borderColor: `${color}30` }">{{ role }}</span>
|
||||
</div>
|
||||
<p class="card-desc">{{ description }}</p>
|
||||
<div class="card-tags">
|
||||
<span v-for="tag in tags" :key="tag" class="card-tag" :style="{ background: `${color}18`, color: color }">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer-action">
|
||||
<span>ROLE CARD</span>
|
||||
<span class="arrow">→</span>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agent-card {
|
||||
background: var(--panel, #11141b);
|
||||
border: 1px solid var(--line, #1f2330);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.agent-card:hover {
|
||||
border-color: var(--card-color, #8b7cf6);
|
||||
box-shadow: 0 0 16px color-mix(in srgb, var(--card-color, #8b7cf6) 10%, transparent);
|
||||
}
|
||||
.hero-card {
|
||||
border-color: rgba(139, 124, 246, 0.2);
|
||||
box-shadow: 0 0 20px rgba(139, 124, 246, 0.06);
|
||||
}
|
||||
.hero-card:hover {
|
||||
border-color: #8b7cf6;
|
||||
box-shadow: 0 0 24px rgba(139, 124, 246, 0.12);
|
||||
}
|
||||
.card-main {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.card-icon-wrap {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.card-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.card-name {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e8eaf0;
|
||||
}
|
||||
.card-role-tag {
|
||||
display: inline-block;
|
||||
font-size: 8.5px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.card-desc {
|
||||
font-size: 10.5px;
|
||||
color: #7e8799;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.card-tag {
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 5px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.card-footer-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--line, #1f2330);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
color: #6b7385;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
.card-footer-action .arrow {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
.agent-card:hover .card-footer-action {
|
||||
color: var(--card-color, #8b7cf6);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
status: string
|
||||
}>()
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
Online: '#51d49a',
|
||||
Degraded: '#e5b05e',
|
||||
Offline: '#e16e75',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="status-badge"
|
||||
:style="{ background: (statusColors[status] || '#7e8799') + '18', color: statusColors[status] || '#7e8799' }"
|
||||
>
|
||||
<span class="status-dot" :style="{ background: statusColors[status] || '#7e8799' }"></span>
|
||||
{{ status }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user