2ac894b5d1
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
943 lines
29 KiB
Vue
943 lines
29 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted, onUnmounted } from "vue";
|
|
import { apiGet, listEvents } from "@/api/client";
|
|
import { useBackgroundRefresh } from "@/composables/useBackgroundRefresh";
|
|
import { milestoneColor } from "@/utils/palette";
|
|
import { fmtRelativeDateTime } from "@/utils/dateFormat";
|
|
import type { Note } from "@/types/note";
|
|
import type { Task, TaskListResponse, TaskStatus } from "@/types/task";
|
|
import type { EventEntry } from "@/api/client";
|
|
import NoteCard from "@/components/NoteCard.vue";
|
|
import TaskCard from "@/components/TaskCard.vue";
|
|
import StatusBadge from "@/components/StatusBadge.vue";
|
|
import PriorityBadge from "@/components/PriorityBadge.vue";
|
|
import ChatPanel from "@/components/ChatPanel.vue";
|
|
import EventSlideOver from "@/components/EventSlideOver.vue";
|
|
import { useTasksStore } from "@/stores/tasks";
|
|
import { useChatStore } from "@/stores/chat";
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
interface MilestoneSummary {
|
|
id: number;
|
|
title: string;
|
|
status: string;
|
|
pct: number;
|
|
total: number;
|
|
completed: number;
|
|
}
|
|
|
|
interface DashProject {
|
|
id: number;
|
|
title: string;
|
|
status: string;
|
|
updated_at: string;
|
|
summary?: {
|
|
task_counts: { todo?: number; in_progress?: number; done?: number };
|
|
milestone_summary: MilestoneSummary[];
|
|
};
|
|
}
|
|
|
|
// A recent item from the hero project (note or task — both come from /api/notes?all=true)
|
|
interface RecentItem {
|
|
id: number;
|
|
title: string;
|
|
status: string | null;
|
|
project_id: number | null;
|
|
updated_at: string;
|
|
}
|
|
|
|
// ─── State ────────────────────────────────────────────────────────────────────
|
|
|
|
const loading = ref(true);
|
|
const tasksStore = useTasksStore();
|
|
|
|
// Hero project — most recently active project
|
|
const heroProject = ref<DashProject | null>(null);
|
|
const heroRecentItems = ref<RecentItem[]>([]);
|
|
const heroNextUp = ref<Task | null>(null);
|
|
|
|
// All other active projects (hero excluded)
|
|
const activeProjects = ref<DashProject[]>([]);
|
|
|
|
// Orphaned items (no project_id)
|
|
const orphanTasks = ref<Task[]>([]);
|
|
const orphanNotes = ref<Note[]>([]);
|
|
const inboxOpen = ref(true);
|
|
|
|
// Upcoming events (today + next 7 days)
|
|
const upcomingEvents = ref<EventEntry[]>([]);
|
|
const eventSlideOverOpen = ref(false);
|
|
const editingEvent = ref<EventEntry | null>(null);
|
|
|
|
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
|
|
// Never touches `loading` so existing content stays on screen while fetching.
|
|
|
|
function _dateRange() {
|
|
const today = new Date()
|
|
const nextWeek = new Date(today)
|
|
nextWeek.setDate(today.getDate() + 7)
|
|
// Use full ISO strings so the server sees the correct UTC equivalent of
|
|
// local midnight / end-of-day rather than a naive UTC-midnight guess.
|
|
return {
|
|
todayStr: today.toISOString(),
|
|
nextWeekStr: nextWeek.toISOString(),
|
|
}
|
|
}
|
|
|
|
function _backgroundRefresh() {
|
|
if (document.hidden || loading.value) return
|
|
const { todayStr, nextWeekStr } = _dateRange()
|
|
Promise.allSettled([
|
|
listEvents(todayStr, nextWeekStr),
|
|
apiGet<TaskListResponse>('/api/tasks?no_project=true&sort=updated_at&order=desc&limit=8'),
|
|
apiGet<{ notes: Note[] }>('/api/notes?type=note&no_project=true&sort=updated_at&order=desc&limit=6'),
|
|
]).then(([eventsRes, tasksRes, notesRes]) => {
|
|
if (eventsRes.status === 'fulfilled') upcomingEvents.value = eventsRes.value
|
|
if (tasksRes.status === 'fulfilled') orphanTasks.value = tasksRes.value.tasks
|
|
if (notesRes.status === 'fulfilled') orphanNotes.value = notesRes.value.notes
|
|
})
|
|
if (heroProject.value) {
|
|
apiGet<TaskListResponse>(
|
|
`/api/tasks?project_id=${heroProject.value.id}&status=todo&sort=updated_at&order=desc&limit=1`
|
|
)
|
|
.then((r) => { heroNextUp.value = r.tasks[0] ?? null })
|
|
.catch(() => {})
|
|
}
|
|
}
|
|
|
|
// ─── Data loading ─────────────────────────────────────────────────────────────
|
|
|
|
onMounted(async () => {
|
|
// Phase 1: projects list + cross-project recent items + orphaned items + events — all parallel
|
|
const { todayStr, nextWeekStr } = _dateRange();
|
|
|
|
const [projectsRes, recentRes, orphanTasksRes, orphanNotesRes, eventsRes] =
|
|
await Promise.allSettled([
|
|
apiGet<{ projects: DashProject[] }>("/api/projects?status=active"),
|
|
apiGet<{ notes: RecentItem[] }>(
|
|
"/api/notes?all=true&sort=updated_at&order=desc&limit=30"
|
|
),
|
|
apiGet<TaskListResponse>(
|
|
"/api/tasks?no_project=true&sort=updated_at&order=desc&limit=8"
|
|
),
|
|
apiGet<{ notes: Note[] }>(
|
|
"/api/notes?type=note&no_project=true&sort=updated_at&order=desc&limit=6"
|
|
),
|
|
listEvents(todayStr, nextWeekStr),
|
|
]);
|
|
|
|
// Determine hero project: the project whose item was most recently touched
|
|
let heroId: number | null = null;
|
|
if (recentRes.status === "fulfilled") {
|
|
const withProject = recentRes.value.notes.find(
|
|
(n) => n.project_id != null
|
|
);
|
|
heroId = withProject?.project_id ?? null;
|
|
}
|
|
|
|
if (projectsRes.status === "fulfilled") {
|
|
const all = projectsRes.value.projects;
|
|
const hero = all.find((p) => p.id === heroId) ?? all[0] ?? null;
|
|
heroProject.value = hero;
|
|
activeProjects.value = all.filter((p) => p.id !== hero?.id);
|
|
}
|
|
|
|
if (orphanTasksRes.status === "fulfilled")
|
|
orphanTasks.value = orphanTasksRes.value.tasks;
|
|
if (orphanNotesRes.status === "fulfilled")
|
|
orphanNotes.value = orphanNotesRes.value.notes;
|
|
if (eventsRes.status === "fulfilled")
|
|
upcomingEvents.value = eventsRes.value;
|
|
|
|
loading.value = false;
|
|
|
|
// Focus chat input after data loads
|
|
chatPanelRef.value?.focus();
|
|
loadProjects();
|
|
|
|
});
|
|
|
|
useBackgroundRefresh(_backgroundRefresh, 90_000, () => !loading.value);
|
|
|
|
async function loadProjects() {
|
|
if (!heroProject.value) return;
|
|
const hid = heroProject.value.id;
|
|
|
|
// Phase 2: hero details + all project summaries — parallel
|
|
const [recentItemsRes, nextUpRes, heroSummaryRes] = await Promise.allSettled([
|
|
apiGet<{ notes: RecentItem[] }>(
|
|
`/api/notes?all=true&project_id=${hid}&sort=updated_at&order=desc&limit=5`
|
|
),
|
|
apiGet<TaskListResponse>(
|
|
`/api/tasks?project_id=${hid}&status=todo&sort=updated_at&order=desc&limit=1`
|
|
),
|
|
apiGet<DashProject>(`/api/projects/${hid}`),
|
|
]);
|
|
|
|
if (recentItemsRes.status === "fulfilled")
|
|
heroRecentItems.value = recentItemsRes.value.notes;
|
|
if (nextUpRes.status === "fulfilled")
|
|
heroNextUp.value = nextUpRes.value.tasks[0] ?? null;
|
|
if (heroSummaryRes.status === "fulfilled")
|
|
heroProject.value = { ...heroProject.value!, ...heroSummaryRes.value };
|
|
|
|
// Load summaries for remaining projects
|
|
await Promise.allSettled(
|
|
activeProjects.value.map(async (p) => {
|
|
try {
|
|
const full = await apiGet<DashProject>(`/api/projects/${p.id}`);
|
|
const idx = activeProjects.value.findIndex((x) => x.id === p.id);
|
|
if (idx !== -1)
|
|
activeProjects.value[idx] = { ...activeProjects.value[idx], ...full };
|
|
} catch {
|
|
/* non-fatal */
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
// ─── Status toggle (orphaned tasks) ──────────────────────────────────────────
|
|
|
|
function onStatusToggle(id: number, status: TaskStatus) {
|
|
tasksStore.patchStatus(id, status).then((updated) => {
|
|
if (updated.status === "done") {
|
|
orphanTasks.value = orphanTasks.value.filter((t) => t.id !== id);
|
|
} else {
|
|
const idx = orphanTasks.value.findIndex((t) => t.id === id);
|
|
if (idx !== -1) orphanTasks.value[idx] = updated;
|
|
}
|
|
});
|
|
}
|
|
|
|
// ─── Chat widget ──────────────────────────────────────────────────────────────
|
|
|
|
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
|
|
|
|
function onFocusChatShortcut() {
|
|
chatPanelRef.value?.focus();
|
|
}
|
|
|
|
onMounted(() => {
|
|
document.addEventListener("shortcut:focus-chat", onFocusChatShortcut);
|
|
});
|
|
onUnmounted(() => {
|
|
document.removeEventListener("shortcut:focus-chat", onFocusChatShortcut);
|
|
});
|
|
|
|
const chatStore = useChatStore();
|
|
|
|
chatStore.fetchStatus().then(() => {
|
|
if (chatStore.defaultModel) chatStore.warmModel(chatStore.defaultModel);
|
|
});
|
|
|
|
const QUICK_ACTIONS = [
|
|
"What's due today?",
|
|
"Events this week?",
|
|
"Any overdue tasks?",
|
|
"My high priority tasks?",
|
|
];
|
|
|
|
async function onQuickAction(query: string) {
|
|
await chatPanelRef.value?.send(query);
|
|
}
|
|
|
|
// ─── Upcoming events slide-over ───────────────────────────────────────────────
|
|
|
|
function openEvent(event: EventEntry) {
|
|
editingEvent.value = event;
|
|
eventSlideOverOpen.value = true;
|
|
}
|
|
|
|
function onEventUpdated(event: EventEntry) {
|
|
const idx = upcomingEvents.value.findIndex((e) => e.id === event.id);
|
|
if (idx !== -1) upcomingEvents.value[idx] = event;
|
|
eventSlideOverOpen.value = false;
|
|
}
|
|
|
|
function onEventDeleted(id: number) {
|
|
upcomingEvents.value = upcomingEvents.value.filter((e) => e.id !== id);
|
|
eventSlideOverOpen.value = false;
|
|
}
|
|
|
|
function formatUpcomingTime(event: EventEntry): string {
|
|
if (!event.start_dt) return "";
|
|
return fmtRelativeDateTime(event.start_dt, event.all_day);
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<main class="home">
|
|
|
|
<!-- ── Chat widget ─────────────────────────────────────────── -->
|
|
<section class="chat-section">
|
|
<div class="quick-actions">
|
|
<button
|
|
v-for="q in QUICK_ACTIONS"
|
|
:key="q"
|
|
class="quick-action-chip"
|
|
:disabled="chatStore.streaming || !chatStore.chatReady"
|
|
@click="onQuickAction(q)"
|
|
>{{ q }}</button>
|
|
</div>
|
|
<ChatPanel ref="chatPanelRef" variant="widget" />
|
|
</section>
|
|
|
|
<!-- ── Upcoming events ────────────────────────────────────── -->
|
|
<div v-if="!loading && upcomingEvents.length" class="upcoming-events-section">
|
|
<div class="section-header">
|
|
<h2>Upcoming</h2>
|
|
<router-link to="/calendar" class="see-all">Calendar →</router-link>
|
|
</div>
|
|
<div class="upcoming-events-list">
|
|
<button
|
|
v-for="ev in upcomingEvents.slice(0, 6)"
|
|
:key="ev.id"
|
|
class="upcoming-event-card"
|
|
@click="openEvent(ev)"
|
|
>
|
|
<span class="upcoming-event-dot" :style="ev.color ? { background: ev.color } : {}"></span>
|
|
<span class="upcoming-event-body">
|
|
<span class="upcoming-event-title">{{ ev.title }}</span>
|
|
<span class="upcoming-event-time">{{ formatUpcomingTime(ev) }}</span>
|
|
<span v-if="ev.location" class="upcoming-event-loc">{{ ev.location }}</span>
|
|
</span>
|
|
</button>
|
|
<div v-if="upcomingEvents.length > 6" class="upcoming-events-more">
|
|
+{{ upcomingEvents.length - 6 }} more —
|
|
<router-link to="/calendar" class="see-all-inline">view all</router-link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Skeleton while loading ─────────────────────────────── -->
|
|
<template v-if="loading">
|
|
<div class="skeleton-hero"></div>
|
|
<div class="skeleton-grid">
|
|
<div class="skeleton-card" v-for="i in 3" :key="i"></div>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else>
|
|
|
|
<!-- ── Hero: Last Active Project ──────────────────────── -->
|
|
<div v-if="heroProject" class="hero-card">
|
|
<div class="hero-top">
|
|
<div class="hero-identity">
|
|
<span class="hero-label">Last active</span>
|
|
<router-link :to="`/projects/${heroProject.id}`" class="hero-title">
|
|
{{ heroProject.title }}
|
|
</router-link>
|
|
</div>
|
|
<router-link :to="`/workspace/${heroProject.id}`" class="btn-workspace-hero">
|
|
▶ Open Workspace
|
|
</router-link>
|
|
</div>
|
|
|
|
<!-- Milestone bars -->
|
|
<div
|
|
v-if="heroProject.summary?.milestone_summary?.length"
|
|
class="hero-milestones"
|
|
>
|
|
<div
|
|
v-for="(ms, i) in heroProject.summary.milestone_summary
|
|
.filter((m) => m.status !== 'archived')
|
|
.slice(0, 3)"
|
|
:key="ms.id"
|
|
class="ms-row"
|
|
>
|
|
<span class="ms-label">{{ ms.title }}</span>
|
|
<div class="ms-track">
|
|
<div
|
|
class="ms-fill"
|
|
:style="{ width: ms.pct + '%', background: milestoneColor(i) }"
|
|
></div>
|
|
</div>
|
|
<span class="ms-pct">{{ Math.round(ms.pct) }}%</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recent items -->
|
|
<div v-if="heroRecentItems.length" class="hero-recent">
|
|
<span class="hero-section-label">Recent</span>
|
|
<div class="hero-items">
|
|
<router-link
|
|
v-for="item in heroRecentItems"
|
|
:key="item.id"
|
|
:to="item.status != null ? `/tasks/${item.id}` : `/notes/${item.id}`"
|
|
class="hero-item"
|
|
>
|
|
<span class="hero-item-icon">{{ item.status != null ? '◉' : '◈' }}</span>
|
|
<span class="hero-item-title">{{ item.title || 'Untitled' }}</span>
|
|
<StatusBadge
|
|
v-if="item.status"
|
|
:status="(item.status as TaskStatus)"
|
|
class="hero-item-badge"
|
|
/>
|
|
</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Next up -->
|
|
<div v-if="heroNextUp" class="hero-next-up">
|
|
<span class="hero-section-label">Next up</span>
|
|
<router-link :to="`/tasks/${heroNextUp.id}`" class="hero-next-link">
|
|
<PriorityBadge :priority="heroNextUp.priority ?? 'none'" />
|
|
{{ heroNextUp.title }}
|
|
</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Active Projects grid ────────────────────────────── -->
|
|
<div v-if="activeProjects.length" class="projects-section">
|
|
<div class="section-header">
|
|
<h2>Projects</h2>
|
|
<router-link to="/projects" class="see-all">See all →</router-link>
|
|
</div>
|
|
<div class="projects-grid">
|
|
<div
|
|
v-for="project in activeProjects"
|
|
:key="project.id"
|
|
class="project-card"
|
|
>
|
|
<div class="project-card-top">
|
|
<router-link
|
|
:to="`/projects/${project.id}`"
|
|
class="project-card-title"
|
|
>
|
|
{{ project.title }}
|
|
</router-link>
|
|
<router-link
|
|
:to="`/workspace/${project.id}`"
|
|
class="btn-workspace-sm"
|
|
title="Open Workspace"
|
|
>▶</router-link>
|
|
</div>
|
|
|
|
<!-- Urgency badges -->
|
|
<div class="project-urgency">
|
|
<span
|
|
v-if="(project.summary?.task_counts?.in_progress ?? 0) > 0"
|
|
class="urgency-badge urgency-in-progress"
|
|
>{{ project.summary!.task_counts.in_progress }} in progress</span>
|
|
<span
|
|
v-if="(project.summary?.task_counts?.todo ?? 0) > 0"
|
|
class="urgency-badge urgency-todo"
|
|
>{{ project.summary!.task_counts.todo }} todo</span>
|
|
<span
|
|
v-if="project.summary && !project.summary.task_counts.in_progress && !project.summary.task_counts.todo"
|
|
class="urgency-badge urgency-clear"
|
|
>All clear</span>
|
|
<span v-if="!project.summary" class="urgency-badge urgency-loading">Loading…</span>
|
|
</div>
|
|
|
|
<!-- Milestone bars (up to 2) -->
|
|
<template v-if="project.summary?.milestone_summary?.length">
|
|
<div
|
|
v-for="(ms, i) in project.summary.milestone_summary
|
|
.filter((m) => m.status !== 'archived')
|
|
.slice(0, 2)"
|
|
:key="ms.id"
|
|
class="ms-row"
|
|
>
|
|
<span class="ms-label">{{ ms.title }}</span>
|
|
<div class="ms-track">
|
|
<div
|
|
class="ms-fill"
|
|
:style="{ width: ms.pct + '%', background: milestoneColor(i) }"
|
|
></div>
|
|
</div>
|
|
<span class="ms-pct">{{ Math.round(ms.pct) }}%</span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Inbox: orphaned items ───────────────────────────── -->
|
|
<div
|
|
v-if="orphanTasks.length || orphanNotes.length"
|
|
class="inbox-section"
|
|
>
|
|
<button class="inbox-header" @click="inboxOpen = !inboxOpen">
|
|
<span class="inbox-toggle">{{ inboxOpen ? '▼' : '▶' }}</span>
|
|
<h2>
|
|
Inbox
|
|
<span class="inbox-count">{{ orphanTasks.length + orphanNotes.length }}</span>
|
|
</h2>
|
|
<span class="inbox-sub">Notes and tasks without a project</span>
|
|
</button>
|
|
<div v-if="inboxOpen" class="inbox-items">
|
|
<TaskCard
|
|
v-for="task in orphanTasks"
|
|
:key="task.id"
|
|
:task="task"
|
|
compact
|
|
@status-toggle="onStatusToggle"
|
|
/>
|
|
<NoteCard
|
|
v-for="note in orphanNotes"
|
|
:key="note.id"
|
|
:note="note"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
</template>
|
|
|
|
</main>
|
|
|
|
<!-- Event slide-over -->
|
|
<EventSlideOver
|
|
v-if="eventSlideOverOpen"
|
|
:event="editingEvent"
|
|
initial-date=""
|
|
@close="eventSlideOverOpen = false"
|
|
@created="eventSlideOverOpen = false"
|
|
@updated="onEventUpdated"
|
|
@deleted="onEventDeleted"
|
|
/>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.home {
|
|
max-width: var(--page-max-width);
|
|
margin: 2rem auto;
|
|
padding: 0 var(--page-padding-x);
|
|
}
|
|
|
|
/* ─── Chat widget ────────────────────────────────────────────── */
|
|
.chat-section {
|
|
max-width: 720px;
|
|
margin: 0 auto 1.5rem;
|
|
}
|
|
.quick-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
gap: 0.4rem;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
.quick-action-chip {
|
|
padding: 0.3rem 0.75rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 999px;
|
|
background: var(--color-bg-secondary);
|
|
color: var(--color-text);
|
|
font-size: 0.8rem;
|
|
cursor: pointer;
|
|
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
|
}
|
|
.quick-action-chip:hover:not(:disabled) {
|
|
border-color: var(--color-primary);
|
|
color: var(--color-primary);
|
|
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
|
}
|
|
.quick-action-chip:disabled { opacity: 0.4; cursor: default; }
|
|
|
|
/* ─── Skeleton loading ───────────────────────────────────────── */
|
|
.skeleton-hero {
|
|
height: 180px;
|
|
border-radius: var(--radius-lg);
|
|
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
|
|
background-size: 200% 100%;
|
|
animation: skeleton-shimmer 1.4s ease infinite;
|
|
margin-bottom: 1.75rem;
|
|
}
|
|
.skeleton-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
|
gap: 0.75rem;
|
|
margin-bottom: 1.75rem;
|
|
}
|
|
.skeleton-card {
|
|
height: 120px;
|
|
border-radius: var(--radius-md);
|
|
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
|
|
background-size: 200% 100%;
|
|
animation: skeleton-shimmer 1.4s ease infinite;
|
|
}
|
|
@keyframes skeleton-shimmer {
|
|
0% { background-position: 200% 0; }
|
|
100% { background-position: -200% 0; }
|
|
}
|
|
|
|
/* ─── Hero card ──────────────────────────────────────────────── */
|
|
.hero-card {
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-lg);
|
|
padding: 1.25rem 1.5rem;
|
|
margin-bottom: 1.75rem;
|
|
box-shadow: 0 2px 16px rgba(124, 58, 237, 0.08), 0 1px 4px rgba(0, 0, 0, 0.06);
|
|
}
|
|
.hero-top {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.hero-identity {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.2rem;
|
|
}
|
|
.hero-label {
|
|
font-size: 0.7rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.08em;
|
|
color: var(--color-text-muted);
|
|
}
|
|
.hero-title {
|
|
font-size: 1.4rem;
|
|
font-weight: 700;
|
|
color: var(--color-text);
|
|
text-decoration: none;
|
|
letter-spacing: -0.02em;
|
|
line-height: 1.2;
|
|
}
|
|
.hero-title:hover { color: var(--color-primary); }
|
|
|
|
.btn-workspace-hero {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
padding: 0.55rem 1.1rem;
|
|
background: var(--gradient-cta);
|
|
color: #fff;
|
|
border-radius: var(--radius-sm);
|
|
text-decoration: none;
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
white-space: nowrap;
|
|
box-shadow: 0 1px 6px rgba(124, 58, 237, 0.3);
|
|
transition: opacity 0.15s, box-shadow 0.15s;
|
|
flex-shrink: 0;
|
|
}
|
|
.btn-workspace-hero:hover {
|
|
opacity: 0.9;
|
|
box-shadow: 0 3px 14px rgba(124, 58, 237, 0.45);
|
|
}
|
|
|
|
.hero-milestones { margin-bottom: 0.75rem; }
|
|
|
|
.hero-section-label {
|
|
font-size: 0.68rem;
|
|
font-weight: 700;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.07em;
|
|
color: var(--color-text-muted);
|
|
display: block;
|
|
margin-bottom: 0.4rem;
|
|
}
|
|
|
|
.hero-recent { margin-bottom: 0.75rem; }
|
|
.hero-items {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.2rem;
|
|
}
|
|
.hero-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.3rem 0.5rem;
|
|
border-radius: var(--radius-sm);
|
|
text-decoration: none;
|
|
color: var(--color-text-secondary);
|
|
font-size: 0.88rem;
|
|
transition: background 0.12s, color 0.12s;
|
|
}
|
|
.hero-item:hover {
|
|
background: color-mix(in srgb, var(--color-primary) 6%, transparent);
|
|
color: var(--color-text);
|
|
}
|
|
.hero-item-icon { opacity: 0.45; font-size: 0.75rem; flex-shrink: 0; }
|
|
.hero-item-title {
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.hero-item-badge { flex-shrink: 0; }
|
|
|
|
.hero-next-up {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
.hero-next-link {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
color: var(--color-text);
|
|
text-decoration: none;
|
|
font-size: 0.9rem;
|
|
font-weight: 500;
|
|
}
|
|
.hero-next-link:hover { color: var(--color-primary); }
|
|
|
|
/* ─── Projects grid ──────────────────────────────────────────── */
|
|
.projects-section { margin-bottom: 1.75rem; }
|
|
.section-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
.section-header h2 { margin: 0; font-size: 1rem; font-weight: 600; }
|
|
.see-all { font-size: 0.85rem; color: var(--color-primary); text-decoration: none; }
|
|
.see-all:hover { text-decoration: underline; }
|
|
|
|
.projects-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
|
gap: 0.75rem;
|
|
}
|
|
.project-card {
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
padding: 0.85rem 1rem;
|
|
transition: box-shadow 0.18s, border-color 0.15s, transform 0.18s;
|
|
}
|
|
.project-card:hover {
|
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10);
|
|
border-color: var(--color-primary);
|
|
transform: translateY(-2px);
|
|
}
|
|
.project-card-top {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 0.5rem;
|
|
gap: 0.5rem;
|
|
}
|
|
.project-card-title {
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
color: var(--color-text);
|
|
text-decoration: none;
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.project-card-title:hover { color: var(--color-primary); }
|
|
.btn-workspace-sm {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 28px;
|
|
height: 28px;
|
|
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
|
|
color: var(--color-primary);
|
|
border-radius: var(--radius-sm);
|
|
text-decoration: none;
|
|
font-size: 0.75rem;
|
|
flex-shrink: 0;
|
|
transition: background 0.15s, color 0.15s;
|
|
}
|
|
.btn-workspace-sm:hover {
|
|
background: var(--color-primary);
|
|
color: #fff;
|
|
}
|
|
|
|
.project-urgency {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.3rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.urgency-badge {
|
|
font-size: 0.72rem;
|
|
padding: 0.1rem 0.45rem;
|
|
border-radius: var(--radius-pill);
|
|
font-weight: 500;
|
|
}
|
|
.urgency-in-progress {
|
|
background: color-mix(in srgb, #7c3aed 15%, transparent);
|
|
color: #7c3aed;
|
|
}
|
|
.urgency-todo {
|
|
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
|
|
color: var(--color-text-muted);
|
|
}
|
|
.urgency-clear {
|
|
background: color-mix(in srgb, #22c55e 12%, transparent);
|
|
color: #16a34a;
|
|
}
|
|
.urgency-loading { color: var(--color-text-muted); font-style: italic; }
|
|
|
|
/* ─── Inbox ──────────────────────────────────────────────────── */
|
|
.inbox-section {
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
overflow: hidden;
|
|
margin-bottom: 1.75rem;
|
|
}
|
|
.inbox-header {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
padding: 0.75rem 1rem;
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
color: var(--color-text);
|
|
transition: background 0.15s;
|
|
}
|
|
.inbox-header:hover { background: var(--color-bg-secondary); }
|
|
.inbox-toggle { font-size: 0.75rem; color: var(--color-text-muted); }
|
|
.inbox-header h2 {
|
|
margin: 0;
|
|
font-size: 0.95rem;
|
|
font-weight: 600;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
}
|
|
.inbox-count {
|
|
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
|
|
color: var(--color-text-muted);
|
|
font-size: 0.75rem;
|
|
padding: 0.05rem 0.4rem;
|
|
border-radius: var(--radius-pill);
|
|
}
|
|
.inbox-sub {
|
|
font-size: 0.8rem;
|
|
color: var(--color-text-muted);
|
|
margin-left: auto;
|
|
}
|
|
.inbox-items {
|
|
padding: 0.5rem 0.75rem 0.75rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
/* ─── Shared: milestone bars ─────────────────────────────────── */
|
|
.ms-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
margin-bottom: 0.3rem;
|
|
}
|
|
.ms-label {
|
|
font-size: 0.72rem;
|
|
color: var(--color-text-muted);
|
|
width: 5rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
}
|
|
.ms-track {
|
|
flex: 1;
|
|
height: 5px;
|
|
background: var(--color-bg-secondary);
|
|
border-radius: 999px;
|
|
overflow: hidden;
|
|
}
|
|
.ms-fill {
|
|
height: 100%;
|
|
border-radius: 999px;
|
|
transition: width 0.3s;
|
|
}
|
|
.ms-pct {
|
|
font-size: 0.7rem;
|
|
color: var(--color-text-muted);
|
|
width: 2.2rem;
|
|
text-align: right;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
/* ─── Mobile ─────────────────────────────────────────────────── */
|
|
@media (max-width: 680px) {
|
|
.home { padding: 0 var(--page-padding-x); margin: 1rem auto; }
|
|
.hero-top { flex-direction: column; align-items: flex-start; }
|
|
.btn-workspace-hero { width: 100%; justify-content: center; }
|
|
.projects-grid { grid-template-columns: 1fr; }
|
|
.inbox-sub { display: none; }
|
|
}
|
|
|
|
/* ─── Upcoming events ────────────────────────────────────────── */
|
|
.upcoming-events-section {
|
|
margin-bottom: 1.75rem;
|
|
}
|
|
.upcoming-events-list {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
|
gap: 0.5rem;
|
|
}
|
|
.upcoming-event-card {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.5rem;
|
|
padding: 0.55rem 0.75rem;
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
cursor: pointer;
|
|
text-align: left;
|
|
font-family: inherit;
|
|
transition: border-color 0.15s, background 0.15s;
|
|
width: 100%;
|
|
}
|
|
.upcoming-event-card:hover {
|
|
border-color: var(--color-primary);
|
|
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-card));
|
|
}
|
|
.upcoming-event-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
background: var(--color-primary);
|
|
flex-shrink: 0;
|
|
margin-top: 4px;
|
|
}
|
|
.upcoming-event-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.1rem;
|
|
min-width: 0;
|
|
}
|
|
.upcoming-event-title {
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: var(--color-text);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.upcoming-event-time {
|
|
font-size: 0.75rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
.upcoming-event-loc {
|
|
font-size: 0.72rem;
|
|
color: var(--color-text-muted);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.upcoming-events-more {
|
|
font-size: 0.8rem;
|
|
color: var(--color-text-muted);
|
|
padding: 0.25rem 0;
|
|
grid-column: 1 / -1;
|
|
}
|
|
.see-all-inline {
|
|
color: var(--color-primary);
|
|
text-decoration: none;
|
|
}
|
|
.see-all-inline:hover { text-decoration: underline; }
|
|
</style>
|