<script setup lang="ts">
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useToast } from 'gnexus-ui-kit/vue'
import { celebrateEarned } from '../gamification'
import { api, ApiError, type Attachment, type Project, type Tag, type Task } from '../api'
import { useRealtimeRefresh } from '../realtime'
import { deadlineOverdue, gradeToPriority, priorityOptions, priorityToGrade, priorityLabel, priorityVariant, relevanceLabel, renderMarkdown, setPageTitle, statusOptions } from '../taskui'
import PriorityBadge from '../components/PriorityBadge.vue'
import MdEditor from '../components/MdEditor.vue'
import MdLightbox from '../components/MdLightbox.vue'
import InlineField from '../components/InlineField.vue'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import TaskForm from '../components/TaskForm.vue'
import TaskCard from '../components/TaskCard.vue'
import Pagination from '../components/Pagination.vue'
defineOptions({ name: 'ProjectView' })
// Страница проекта: двухколоночная раскладка. Заметка, список задач с фильтрами
// и дерево — в основной колонке; статистика и параметры — в сайдбаре.
// Создание задачи здесь = createTask + PATCH project_id (TaskCreate не принимает
// project_id) — задача попадёт в стек на детализацию.
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const toast = useToast()
const projectId = computed(() => Number(route.params.id))
const project = ref<Project | null>(null)
const tasks = ref<Task[]>([])
const tags = ref<Tag[]>([])
const error = ref('')
const loading = ref(false)
const notFound = ref(false)
// Тайтл вкладки — название проекта (после загрузки уточняет meta маршрута)
watchEffect(() => {
setPageTitle(project.value?.name ?? t('nav.projects'))
})
// Фильтры списка: статус (по умолчанию «К выполнению»), тег. Выбор пользователя
// запоминается в localStorage с привязкой к проекту — вернувшись, видит те же
// фильтры; в приватном окне/при очищенных данных просто работают дефолты.
const filters = ref({ status: 'to_do', tag_id: '' })
const filtersKey = computed(() => `gntodo:project-filters:${projectId.value}`)
function loadStoredFilters() {
try {
const raw = localStorage.getItem(filtersKey.value)
if (raw) {
const saved = JSON.parse(raw) as { status?: string; tag_id?: string }
filters.value = {
status: saved.status ?? 'to_do',
tag_id: saved.tag_id ?? '',
}
}
} catch {
// битый JSON или недоступный localStorage — не мешаем работе
}
}
watch(filtersKey, loadStoredFilters, { immediate: true })
// deep: ref хранит объект — по умолчанию watch видит лишь замену .value,
// а селекты мутируют поля (v-model="filters.status")
watch(filters, () => {
try {
localStorage.setItem(filtersKey.value, JSON.stringify(filters.value))
} catch {
// переполнение/запрет localStorage — фильтры просто не запомнятся
}
}, { deep: true })
const filteredTasks = computed(() =>
tasks.value.filter(
(task) =>
(!filters.value.status || task.status === filters.value.status) &&
(!filters.value.tag_id || task.tags.some((tag) => String(tag.id) === filters.value.tag_id)),
),
)
// Пагинация: по 30 карточек, смена фильтра — с первой
const page = ref(1)
const pagedTasks = computed(() =>
filteredTasks.value.slice((page.value - 1) * 30, page.value * 30),
)
watch(filters, () => {
page.value = 1
}, { deep: true })
// Статистика по всем задачам проекта (фильтры не влияют)
const stats = computed(() => {
const list = tasks.value
const open = list.filter((task) => task.status !== 'done' && task.status !== 'cancelled')
return {
total: list.length,
done: list.filter((task) => task.status === 'done').length,
inProgress: list.filter((task) => task.status === 'in_progress').length,
toDo: list.filter((task) => task.status === 'to_do').length,
overdue: open.filter((task) => deadlineOverdue(task)).length,
}
})
const doneCount = computed(() => stats.value.done)
// Сайдбар «Статистика» — строка = иконка + термин + значение
const statRows = computed(() => {
const s = stats.value
const rows: { key: string; icon: string; term: string; value: string; warn?: boolean }[] = [
{ key: 'total', icon: 'ph-stack', term: t('projectPage.total'), value: String(s.total) },
{ key: 'done', icon: 'ph-check-circle', term: t('projectPage.doneN'), value: String(s.done) },
{ key: 'inProgress', icon: 'ph-circle-half', term: t('projectPage.inProgress'), value: String(s.inProgress) },
{ key: 'toDo', icon: 'ph-circle', term: t('projectPage.toDo'), value: String(s.toDo) },
]
if (s.overdue)
rows.push({ key: 'overdue', icon: 'ph-alarm', term: t('projectPage.overdue'), value: String(s.overdue), warn: true })
return rows
})
// Сайдбар «Параметры» проекта — обе строки всегда (пустой приоритет — «не указано»,
// кликабельно); обе строки инлайн-редактируемые
const paramRows = computed(() => {
const project0 = project.value
if (!project0) return []
return [
{
key: 'relevance',
icon: 'ph-activity',
term: t('projects.form.relevance'),
value: relevanceLabel(project0.relevance_status),
badge: undefined,
empty: false,
inline: true,
},
{
key: 'priority',
icon: 'ph-flag',
term: t('projects.form.priority'),
value: project0.priority === null ? '' : priorityLabel(project0.priority),
badge: project0.priority === null ? undefined : priorityVariant(project0.priority),
empty: project0.priority === null,
inline: true,
},
]
})
// Теги, используемые в задачах проекта, с количеством
const tagRows = computed(() => {
const counts = new Map<string, number>()
tasks.value.forEach((task) => task.tags.forEach((tag) => counts.set(tag.name, (counts.get(tag.name) ?? 0) + 1)))
return [...counts.entries()].sort((a, b) => b[1] - a[1])
})
const noteHtml = computed(() => (project.value ? renderMarkdown(project.value.note) : ''))
// Клик по картинке в заметке → просмотр на весь экран
const noteImage = ref<{ url: string; name: string } | null>(null)
function onNoteClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (target.tagName === 'IMG') {
const img = target as HTMLImageElement
noteImage.value = { url: img.getAttribute('src') ?? '', name: img.alt }
}
}
// Картинки в заметке: загрузка из буфера (MdEditor вставит markdown в курсор)
async function uploadImages(files: File[]): Promise<Attachment[]> {
const project0 = project.value
if (!project0?.document_id) return []
try {
return await api.uploadAttachments(project0.document_id, files)
} catch (e) {
toast.error({ title: t('common.error'), text: String(e) })
return []
}
}
// Токен запроса: поздний ответ для старого id (быстрая навигация по проектам)
// не должен перетирать данные нового
let loadSeq = 0
async function load() {
const seq = ++loadSeq
loading.value = true
notFound.value = false
error.value = ''
try {
const [fetched, projectTasks, tagList] = await Promise.all([
api.getProject(projectId.value),
api.listTasks({ project_id: projectId.value }),
api.listTags(),
])
if (seq !== loadSeq) return
project.value = fetched
tasks.value = projectTasks
tags.value = tagList
} catch (e) {
if (seq !== loadSeq) return
if (e instanceof ApiError && e.status === 404) {
notFound.value = true
} else {
error.value = String(e)
}
} finally {
if (seq === loadSeq) loading.value = false
}
}
onMounted(load)
// Реактивность (ТЗ 3.14): задачи проекта меняют MCP и соседние вкладки,
// сам проект могут переименовать/заархивировать — перезагружаемся без навигации
useRealtimeRefresh(
() => {
if (projectId.value) void load()
},
['task.changed', 'task.deleted', 'project.changed', 'project.deleted'],
)
// /projects/:id → /projects/:id переиспользует инстанс компонента —
// перезагружаемся по смене id (та же логика, что в TaskView)
watch(projectId, () => {
void load()
})
// ---------- Инлайн-редактирование полей проекта ----------
// Draft сеется на @open, ✓ шлёт PATCH; ответ заменяет project.value —
// шапка, бейджи и заметка пересчитаются сами.
const draftName = ref('')
const draftNote = ref('')
const noteEditing = ref(false)
const noteBusy = ref(false)
const draftRelevance = ref('')
const draftPriority = ref('')
const priorityOptionsWithNone = computed(() => [
{ value: '', label: t('common.none') },
...priorityOptions(),
])
// Инлайн-PATCH: ошибка → тост + throw (InlineField оставит редактор открытым)
async function patchProject(patch: Partial<Project>) {
const project0 = project.value
if (!project0) return
try {
project.value = await api.updateProject(project0.id, patch)
} catch (e) {
toast.error({ title: t('common.error'), text: String(e) })
throw e
}
}
async function saveName() {
const name = draftName.value.trim()
if (!name) throw new Error('empty') // пустое имя не сохраняем
await patchProject({ name })
}
async function saveNote() {
noteBusy.value = true
try {
await patchProject({ note: draftNote.value })
noteEditing.value = false
} catch {
// тост уже показан patchProject
} finally {
noteBusy.value = false
}
}
async function saveRelevance() {
await patchProject({ relevance_status: draftRelevance.value })
}
async function savePriority() {
await patchProject({
priority: draftPriority.value === '' ? null : gradeToPriority(draftPriority.value),
})
}
// Архив (ТЗ 3.11): у архивного проекта чтение и история доступны,
// создание задач и правки не имеют смысла — они спрятаны в шаблоне
const isArchived = computed(() => project.value?.is_archived ?? false)
async function restore() {
const project0 = project.value
if (!project0) return
try {
project.value = await api.restoreProject(project0.id)
toast.success({ title: t('archive.restored', { name: project0.name }) })
} catch (e) {
toast.error({ title: t('common.error'), text: String(e) })
}
}
// Действия над самим проектом — дропдаун «…» в шапке: правка (drawer),
// архив/восстановление, удаление; разрушительное — через подтверждение
const archiveConfirmOpen = ref(false)
const projectDeleteOpen = ref(false)
const projectActions = computed(() =>
isArchived.value
? [
{ label: t('archive.restore'), icon: 'ph-arrow-counter-clockwise', onSelect: restore },
{ label: t('common.delete'), icon: 'ph-trash', danger: true, onSelect: () => (projectDeleteOpen.value = true) },
]
: [
{ label: t('common.edit'), icon: 'ph-pencil-simple', onSelect: startEdit },
{ label: t('common.archive'), icon: 'ph-archive', onSelect: () => (archiveConfirmOpen.value = true) },
{ label: t('common.delete'), icon: 'ph-trash', danger: true, onSelect: () => (projectDeleteOpen.value = true) },
],
)
async function archiveSelf() {
const project0 = project.value
if (!project0) return
archiveConfirmOpen.value = false
try {
await api.archiveProject(project0.id)
toast.success({ title: t('projects.archived') })
void router.push('/projects')
} catch (e) {
toast.error({ title: t('common.error'), text: String(e) })
}
}
async function deleteSelf() {
const project0 = project.value
if (!project0) return
projectDeleteOpen.value = false
try {
await api.deleteProject(project0.id)
toast.success({ title: t('common.projectDeleted') })
void router.push('/projects')
} catch (e) {
toast.error({ title: t('common.error'), text: String(e) })
}
}
// Правка проекта — drawer, как в списке проектов
const drawerOpen = ref(false)
const editForm = ref({
name: '',
relevance_status: 'active',
priorityGrade: '' as string,
note: '',
})
const relevanceOptions = computed(() =>
// «Закрыт» больше нет — вместо него архив (ТЗ 3.11)
['active', 'paused'].map((value) => ({ value, label: relevanceLabel(value) })),
)
function startEdit() {
if (!project.value) return
editForm.value = {
name: project.value.name,
relevance_status: project.value.relevance_status,
priorityGrade: project.value.priority === null ? '' : priorityToGrade(project.value.priority),
note: project.value.note,
}
drawerOpen.value = true
}
function closeDrawer() {
drawerOpen.value = false
}
async function saveEdit() {
if (!project.value) return
try {
await api.updateProject(project.value.id, {
name: editForm.value.name,
relevance_status: editForm.value.relevance_status,
priority:
editForm.value.priorityGrade === '' ? null : gradeToPriority(editForm.value.priorityGrade),
note: editForm.value.note,
})
toast.success({ title: t('common.saved') })
closeDrawer()
await load()
} catch (e) {
toast.error({ title: t('common.error'), text: String(e) })
}
}
async function setStatus(_task: Task, status: string) {
error.value = ''
try {
await api.updateTask(_task.id, { status })
celebrateEarned()
await load()
} catch (e) {
error.value = String(e)
}
}
// Удаление задачи — через подтверждение
const deleteTarget = ref<Task | null>(null)
const confirmOpen = ref(false)
function askDelete(task: Task) {
deleteTarget.value = task
confirmOpen.value = true
}
async function remove() {
const task = deleteTarget.value
if (!task) return
confirmOpen.value = false
try {
await api.deleteTask(task.id)
toast.success({ title: t('common.taskDeleted') })
await load()
} catch (e) {
toast.error({ title: t('common.error'), text: String(e) })
}
}
// Новая задача проекта: полная форма (TaskForm create + project_id по умолчанию)
const newTaskOpen = ref(false)
// После создания ведём пользователя на страницу задачи — детали заполняются там
function onTaskCreated(task: Task) {
newTaskOpen.value = false
void load()
void router.push(`/tasks/${task.id}`)
}
const statusOptionsWithAny = computed(() => [
{ value: '', label: t('common.anyStatus') },
...statusOptions(),
])
</script>
<template>
<section>
<GnSkeleton v-if="loading && !project" type="block" stack :count="2" class="page-skeleton" />
<GnEmptyState
v-if="!loading && notFound"
icon="ph-folder-simple"
:title="t('projectPage.notFoundTitle')"
:text="t('projectPage.notFoundText')"
>
<template #actions>
<GnButton variant="primary" icon="ph-folders" @click="router.push('/projects')">
{{ t('nav.projects') }}
</GnButton>
</template>
</GnEmptyState>
<template v-if="project">
<GnPageHeader kicker="GNexus Tasks" :title="project.name">
<template #title>
<!-- Инлайн-правка названия: клик по нему же → поле на его месте -->
<InlineField
v-if="!isArchived"
:label="t('task.editTitle')"
:save="saveName"
@open="draftName = project.name"
>
<template #display="{ edit }">
<button class="title-editable" type="button" @click="edit">{{ project.name }}</button>
</template>
<template #edit="{ save }">
<GnInput
v-model="draftName"
autofocus
class="title-input"
@keydown.enter="save"
/>
</template>
</InlineField>
<span v-else>{{ project.name }}</span>
</template>
<template #meta>
<GnBadge variant="info"><i class="ph ph-activity" aria-hidden="true" />
{{ relevanceLabel(project.relevance_status) }}
</GnBadge>
<PriorityBadge v-if="project.priority !== null" :priority="project.priority" />
<GnBadge variant="neutral"><i class="ph ph-list-checks" aria-hidden="true" />
{{ t('projectPage.taskCount', { n: tasks.length }) }}
</GnBadge>
</template>
<template #actions>
<GnButton v-if="!isArchived" variant="primary" size="sm" icon="ph-plus" class="new-task-btn" @click="newTaskOpen = true">
<span class="new-task-label">{{ t('projectPage.newTask') }}</span>
</GnButton>
<GnDropdown class="project-menu" :items="projectActions">
<template #trigger="{ toggle }">
<GnIconButton icon="ph-dots-three-outline" :label="t('common.actions')" @click="toggle" />
</template>
</GnDropdown>
</template>
</GnPageHeader>
<GnAlert v-if="error" variant="error">{{ error }}</GnAlert>
<!-- Проект в архиве: только история, восстановление из плашки -->
<GnAlert v-if="isArchived" variant="warning" class="archived-banner">
<i class="ph ph-archive" aria-hidden="true" />
{{ t('archive.banner') }}
<GnButton variant="success" size="sm" icon="ph-arrow-counter-clockwise" class="banner-restore" @click="restore">
{{ t('archive.restore') }}
</GnButton>
</GnAlert>
<div class="project-grid">
<div class="project-main">
<!-- Заметка (Markdown: ссылки, контекст): карандаш — правка прямо здесь -->
<GnCard class="note-card">
<template #title>
<span class="card-title-icon"><i class="ph ph-notebook" aria-hidden="true" /></span>
{{ t('projectPage.note') }}
<GnIconButton
v-if="!noteEditing && !isArchived"
icon="ph-pencil-simple"
size="sm"
:label="t('projectPage.editNote')"
@click="noteEditing = true; draftNote = project.note"
/>
</template>
<div v-if="noteEditing" class="inline-note">
<MdEditor v-model="draftNote" caption="" :rows="8" :upload-images="uploadImages" />
<span class="inline-actions">
<GnIconButton
icon="ph-check"
size="sm"
:label="t('common.save')"
:disabled="noteBusy"
@click="saveNote"
/>
<GnIconButton
icon="ph-x"
size="sm"
:label="t('common.cancel')"
:disabled="noteBusy"
@click="noteEditing = false"
/>
</span>
</div>
<template v-else>
<div
v-if="project.note.trim()"
class="md-view"
v-html="noteHtml"
@click="onNoteClick"
/>
<p v-else class="muted">
<i class="ph ph-note-blank" aria-hidden="true" />
{{ t('projectPage.noteEmpty') }}
</p>
</template>
</GnCard>
<MdLightbox
v-if="noteImage"
:src="noteImage.url"
:alt="noteImage.name"
@close="noteImage = null"
/>
<!-- Прогресс: выполнено задач проекта -->
<GnProgress
class="progress"
:value="doneCount"
:max="Math.max(tasks.length, 1)"
:label="t('projectPage.progress', { done: doneCount, total: tasks.length })"
/>
<!-- Фильтры списка -->
<div class="filters">
<GnSelect
v-model="filters.status"
:label="t('list.filterStatus')"
icon="ph-funnel"
:options="statusOptionsWithAny"
/>
<GnSelect
v-model="filters.tag_id"
:label="t('list.filterTag')"
icon="ph-tag"
:options="[
{ value: '', label: t('common.any') },
...tags.map((tg) => ({ value: String(tg.id), label: tg.name })),
]"
/>
</div>
<GnEmptyState
v-if="filteredTasks.length === 0"
icon="ph-magnifying-glass"
:title="t('projectPage.emptyTitle')"
:text="t('projectPage.emptyText')"
/>
<div class="task-list">
<TaskCard
v-for="task in pagedTasks"
:key="task.id"
:task="task"
:show-project="false"
@status-change="setStatus"
@delete="askDelete"
/>
</div>
<Pagination v-model:page="page" :total="filteredTasks.length" />
</div>
<!-- Сайдбар: статистика, параметры, теги -->
<aside class="project-side">
<GnCard class="side-card">
<template #title>
<span class="card-title-icon"><i class="ph ph-chart-bar" aria-hidden="true" /></span>
{{ t('projectPage.stats') }}
</template>
<div class="p-rows">
<div v-for="row in statRows" :key="row.key" class="p-row" :class="{ warn: row.warn }">
<i :class="`ph ${row.icon}`" aria-hidden="true" />
<span class="p-term">{{ row.term }}</span>
<span class="p-value">{{ row.value }}</span>
</div>
</div>
</GnCard>
<GnCard class="side-card">
<template #title>
<span class="card-title-icon"><i class="ph ph-list-dashes" aria-hidden="true" /></span>
{{ t('task.fields') }}
</template>
<div class="p-rows">
<div
v-for="row in paramRows"
:key="row.key"
class="p-row editing"
>
<i :class="`ph ${row.icon}`" aria-hidden="true" />
<span class="p-term">{{ row.term }}</span>
<!-- Архив: только чтение -->
<template v-if="isArchived">
<GnBadge v-if="row.badge" :variant="row.badge" class="p-value-badge">
{{ row.value }}
</GnBadge>
<span v-else class="p-value" :class="{ empty: row.empty }">
{{ row.empty ? t('common.notSet') : row.value }}
</span>
</template>
<!-- Актуальность: select -->
<InlineField
v-else-if="row.key === 'relevance'"
below
:label="t('projects.form.relevance')"
:save="saveRelevance"
@open="draftRelevance = project.relevance_status"
>
<template #display="{ edit }">
<span class="p-value clickable" @click="edit">{{ row.value }}</span>
</template>
<template #edit>
<GnSelect
v-model="draftRelevance"
:options="relevanceOptions"
class="inline-select"
/>
</template>
</InlineField>
<!-- Приоритет: select -->
<InlineField
v-else
below
:label="t('projects.form.priority')"
:save="savePriority"
@open="draftPriority = project.priority === null ? '' : priorityToGrade(project.priority)"
>
<template #display="{ edit }">
<GnBadge
v-if="row.badge"
:variant="row.badge"
class="p-value-badge"
@click="edit"
>
<i class="ph ph-flag" aria-hidden="true" />{{ row.value }}
</GnBadge>
<span v-else class="p-value empty clickable" @click="edit">
{{ t('common.notSet') }}
</span>
</template>
<template #edit>
<GnSelect
v-model="draftPriority"
:options="priorityOptionsWithNone"
class="inline-select"
/>
</template>
</InlineField>
</div>
</div>
</GnCard>
<GnCard v-if="tagRows.length" class="side-card">
<template #title>
<span class="card-title-icon"><i class="ph ph-tag" aria-hidden="true" /></span>
{{ t('stack.form.tags') }}
</template>
<div class="p-rows">
<div v-for="[name, count] in tagRows" :key="name" class="p-row">
<i class="ph ph-tag" aria-hidden="true" />
<span class="p-value">{{ name }}</span>
<GnBadge variant="neutral">{{ count }}</GnBadge>
</div>
</div>
</GnCard>
</aside>
</div>
</template>
<GnDrawer v-model:open="newTaskOpen" :title="t('projectPage.newTask')" position="right">
<TaskForm
v-if="newTaskOpen"
:key="projectId"
:project-id="projectId"
@saved="onTaskCreated"
@cancel="newTaskOpen = false"
/>
</GnDrawer>
<GnDrawer v-model:open="drawerOpen" :title="project?.name || t('projects.edit')" position="right" @close="closeDrawer">
<form class="edit-form" @submit.prevent="saveEdit">
<GnInput v-model="editForm.name" :label="t('projects.form.name')" required />
<GnSelect
v-model="editForm.relevance_status"
:label="t('projects.form.relevance')"
icon="ph-activity"
:options="relevanceOptions"
/>
<GnSelect
v-model="editForm.priorityGrade"
:label="t('projects.form.priority')"
icon="ph-flag"
:options="priorityOptionsWithNone"
/>
<MdEditor v-model="editForm.note" :caption="t('projects.form.note')" :rows="6" :upload-images="uploadImages" />
<div class="form-actions">
<GnButton type="submit" variant="success" icon="ph-check-circle">
{{ t('common.save') }}
</GnButton>
<GnButton type="button" variant="primary" @click="closeDrawer">
{{ t('common.cancel') }}
</GnButton>
</div>
</form>
</GnDrawer>
<ConfirmDialog
v-model:open="confirmOpen"
:title="t('common.confirmTitle')"
:message="t('common.deleteConfirmTask', { title: deleteTarget?.title ?? '' })"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
confirm-variant="danger"
confirm-icon="ph-trash"
@confirm="remove"
/>
<ConfirmDialog
v-model:open="archiveConfirmOpen"
:title="t('common.archiveTitle')"
:message="t('projects.archiveConfirm', { name: project?.name ?? '', n: tasks.length })"
:confirm-text="t('common.archive')"
:cancel-text="t('common.cancel')"
confirm-variant="warning"
@confirm="archiveSelf"
/>
<ConfirmDialog
v-model:open="projectDeleteOpen"
:title="t('common.confirmTitle')"
:message="t('common.deleteConfirmProject', { name: project?.name ?? '' })"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
confirm-variant="danger"
confirm-icon="ph-trash"
@confirm="deleteSelf"
/>
</section>
</template>
<style scoped>
/* Плашка архива: текст и кнопка в строку */
.archived-banner {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.banner-restore {
margin-left: auto;
}
.page-skeleton {
margin-bottom: 1rem;
}
/* «Новая задача» в шапке страницы: на мобильном — кнопка-иконка, как «+» топбара
(.btn кита растянут с пустым хвостом после скрытия лейбла) */
@media (max-width: 640px) {
.new-task-btn {
width: auto;
min-height: 38px;
padding: 8px 10px;
justify-content: center;
}
.new-task-label {
display: none;
}
}
/* Две колонки на широком экране: содержимое + сайдбар */
.project-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 960px) {
.project-grid {
grid-template-columns: 1fr;
}
}
.project-main > * + *,
.project-side > * + * {
margin-top: 1.5rem;
}
.md-view {
overflow-x: auto;
font-size: 1.02rem;
line-height: 1.65;
}
.md-view :deep(img) {
max-width: 100%;
height: auto;
}
.p-rows {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.p-row {
display: flex;
gap: 0.6rem;
align-items: baseline;
font-size: 0.95rem;
}
.p-row > i {
flex: 0 0 1.25rem;
text-align: center;
color: var(--accent, #7aa2f7);
font-size: 1.05em;
align-self: center;
}
.p-term {
color: var(--text-muted, #888);
flex: 1 1 auto;
}
.p-value {
flex: 0 0 auto;
font-weight: 600;
overflow-wrap: anywhere;
}
.p-row.warn > i,
.p-row.warn .p-value {
color: var(--danger, #f7768e);
}
/* строки с инлайн-правкой: пока редактор не открыт — обычная строка в один ряд;
при открытом редакторе (below в InlineField) он переносится на отдельную
строку на всю ширину. :has — по факту открытого редактора, а не по классу
строки: класс стоит всегда, показ ломаться не должен */
.p-row.editing {
align-items: center;
}
.p-row:has(.inline-editing) {
flex-wrap: wrap;
}
.p-row:has(.inline-editing) .inline-field {
flex: 1 1 100%;
}
/* кликабельное значение параметра: курсор + подсветка по hover */
.p-value.clickable {
cursor: pointer;
}
.p-value.clickable:hover {
color: var(--accent, #7aa2f7);
}
.p-value.empty {
color: var(--text-muted, #888);
font-weight: normal;
}
.p-value-badge {
cursor: pointer;
}
/* инлайн-редакторы: обёртка .form-group кита в строке — без отступов формы, ужато */
.p-row .form-group {
margin: 0;
min-width: 0;
}
.p-row :deep(.form-group .label) {
margin: 0;
}
.p-row :deep(.form-group .label .input) {
margin-top: 0;
min-height: 0;
padding: 4px 10px;
border-bottom-width: 2px;
font-size: 14px;
}
.p-row :deep(.inline-select) {
min-width: 7rem;
}
/* стрелка/иконочный отступ кита в инлайне не нужен */
.p-row :deep(.select-wrap .select) {
padding-left: 10px;
}
/* название проекта как кнопка правки */
.title-editable {
background: none;
border: none;
padding: 0;
font: inherit;
color: inherit;
text-align: inherit;
cursor: pointer;
overflow-wrap: anywhere;
}
.title-editable:hover {
color: var(--accent, #7aa2f7);
}
/* шапка карточки заметки: текст слева, карандаш справа в цвет заголовка */
.note-card .card-title {
display: flex;
align-items: center;
}
.note-card .card-title .btn-icon {
margin-left: auto;
margin-top: -23px;
color: inherit; /* тёмный цвет текста заголовка — на светлой полосе шапки */
width: 24px;
height: 24px;
font-size: 16px;
}
/* правка названия: поле кита (input.input) наследует h1, компактнее по высоте */
/* правка названия: поле кита (GnInput, обёртка .form-group) наследует h1 */
.page-header :deep(.inline-editing) {
width: 100%;
max-width: 34rem;
flex-wrap: nowrap; /* редактор и кнопки в одной строке шапки */
}
.page-header .form-group {
margin: 0;
flex: 1 1 auto;
min-width: 0;
}
.page-header :deep(.form-group .label) {
margin: 0;
}
.page-header :deep(input.title-input) {
font: inherit;
font-weight: inherit;
min-height: 0;
padding: 4px 12px;
margin-top: 0;
}
/* правка заметки: редактор + кнопки */
.inline-note {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.inline-note .inline-actions {
display: flex;
gap: 0.25rem;
}
.muted {
color: var(--text-muted, #888);
font-size: 0.9em;
}
/* фильтры: подписанные селекты в ряд, кнопка сброса прижата к низу */
.filters {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-end;
margin-bottom: 1rem;
}
/* Прогресс на всю ширину контейнера (в ките .progress упёрт в max-width: 640px) */
.progress {
max-width: 100%;
}
.filters :deep(.form-group) {
width: 14rem;
margin-bottom: 0;
}
.filters :deep(.select-wrap) {
margin-top: 0;
}
.task-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.edit-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-actions {
display: flex;
gap: 0.75rem;
align-items: center;
}
.md-preview {
border: 1px solid var(--border, #333);
padding: 0.75rem;
overflow-x: auto;
}
.md-preview :deep(img) {
max-width: 100%;
height: auto;
}
</style>