<script setup lang="ts">
// Внутренний UI попапа (живёт внутри GnToastProvider в Popup.vue).
// Две вкладки: добавление задачи и задачи «В работе» (только просмотр).
// Аутентификация — Bearer MCP-токен (кука сервиса из extension-контекста не ходит).
// Максимум готовых компонентов кита: GnInput/GnTextarea/GnSelect/GnTabs/GnBadge.
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'gnexus-ui-kit/vue'
import {
createTask,
fetchMe,
listProjects,
listTasks,
openTab,
setToken,
type Project,
type TaskOut,
} from './api'
const toast = useToast()
const DEFAULT_BASE = 'http://localhost:15134'
type Screen = 'loading' | 'loggedOut' | 'ready'
const screen = ref<Screen>('loading')
const userName = ref('')
// Базовый URL сервиса и MCP-токен — из chrome.storage.local.
const base = ref(DEFAULT_BASE)
const settingsOpen = ref(false)
const baseDraft = ref('')
const tokenDraft = ref('')
// GnSelect держит строковое «Без проекта» — на сабмите конвертируется в null
const projectSel = ref<string | number>('')
const tab = ref<'add' | 'tasks'>('add')
const tabs = computed(() => [
{ id: 'add', label: 'Добавить' },
{ id: 'tasks', label: 'В работе' },
])
function onTab(id: unknown): void {
tab.value = id as 'add' | 'tasks'
}
// — Добавление —
const text = ref('')
const projects = ref<Project[]>([])
const projectOptions = computed(() => [
{ value: '', label: 'Без проекта' },
...projects.value.map((p) => ({ value: p.id, label: p.name })),
])
const busy = ref(false)
const success = ref(false)
// — В работе —
const tasks = ref<TaskOut[]>([])
const tasksLoading = ref(false)
const tasksError = ref('')
const inWorkTasks = computed(() => tasks.value.filter((t) => t.status === 'in_progress'))
const today = new Date().toISOString().slice(0, 10)
function isOverdue(t: TaskOut): boolean {
return !!t.deadline_date && t.deadline_date < today
}
async function init(): Promise<void> {
screen.value = 'loading'
try {
const stored = await chrome.storage.local.get(['baseUrl', 'token'])
if (typeof stored.baseUrl === 'string' && stored.baseUrl) base.value = stored.baseUrl
if (typeof stored.token === 'string') {
tokenDraft.value = stored.token
setToken(stored.token)
}
} catch {
// storage недоступен (не-Chromium окружение) — работаем с дефолтом
}
try {
const me = await fetchMe(base.value)
if (!me) {
screen.value = 'loggedOut'
return
}
userName.value = me.user.name
screen.value = 'ready'
void loadProjects()
void loadTasks()
} catch (e) {
toast.error({ title: 'Сервис недоступен', text: String(e) })
screen.value = 'loggedOut'
}
}
async function loadProjects(): Promise<void> {
try {
projects.value = await listProjects(base.value)
} catch {
// без списка проектов поле просто остаётся пустым
}
}
async function loadTasks(): Promise<void> {
tasksLoading.value = true
tasksError.value = ''
try {
tasks.value = await listTasks(base.value)
} catch (e) {
tasksError.value = String(e)
} finally {
tasksLoading.value = false
}
}
async function ensurePermission(origin: string): Promise<boolean> {
if (origin === 'http://localhost:15134') return true // host_permissions в манифесте
const granted = await chrome.permissions.request({ origins: [`${origin}/*`] }).catch(() => false)
return !!granted
}
function toggleSettings(): void {
settingsOpen.value = !settingsOpen.value
if (settingsOpen.value) {
baseDraft.value = base.value
tokenDraft.value = tokenDraft.value || ''
}
}
async function connect(): Promise<void> {
let raw = baseDraft.value.trim()
const token = tokenDraft.value.trim()
if (!raw || !token) {
toast.error({ title: 'Заполните оба поля', text: 'Адрес сервиса и MCP-токен' })
return
}
if (!/^https?:\/\//.test(raw)) raw = `https://${raw}`
raw = raw.replace(/\/+$/, '')
let origin: string
try {
origin = new URL(raw).origin
} catch {
toast.error({ title: 'Не похоже на адрес', text: raw })
return
}
const ok = await ensurePermission(origin)
if (!ok) {
toast.error({ title: 'Доступ не выдан', text: 'Разрешите доступ к адресу — иначе запросы не пройдут' })
return
}
base.value = raw
setToken(token)
await chrome.storage.local.set({ baseUrl: raw, token })
settingsOpen.value = false
toast.success({ title: 'Подключено' })
void init()
}
function openSettings(): void {
openTab(base.value, '/settings')
}
function login(): void {
openTab(base.value, '/login')
}
function openTask(t: TaskOut): void {
openTab(base.value, `/tasks/${t.id}`)
}
function openService(): void {
openTab(base.value)
}
async function submit(): Promise<void> {
if (!text.value.trim() || busy.value) return
busy.value = true
success.value = false
const projectId = projectSel.value === '' ? null : Number(projectSel.value)
try {
const res = await createTask(base.value, text.value, projectId)
// как TaskForm.resetForNext: поле чистится, выбранный проект сохраняется
text.value = ''
toast.success({
title: 'Задача добавлена',
text: res.xp ? `+${res.xp} XP` : undefined,
})
success.value = true
setTimeout(() => {
success.value = false
}, 1600)
void loadTasks()
} catch (e) {
toast.error({ title: 'Не получилось', text: String(e) })
} finally {
busy.value = false
}
}
function onKeydown(e: KeyboardEvent): void {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
void submit()
}
}
onMounted(init)
</script>
<template>
<div class="popup">
<header class="popup-header">
<i class="ph ph-squares-four" aria-hidden="true" />
<span class="app-name">GNexus Tasks</span>
<GnIconButton
v-if="screen === 'ready'"
icon="ph-gear"
size="sm"
label="Настройки"
@click="toggleSettings"
/>
<GnIconButton
v-if="screen === 'ready'"
icon="ph-arrow-up-right"
size="sm"
label="Открыть GNexus Tasks"
@click="openService"
/>
</header>
<!-- Нет токена: вставить MCP-токен из Настроек сервиса -->
<div v-if="screen === 'loggedOut'" class="login">
<p class="muted">
Подключите расширение к GNexus Tasks — нужен MCP-токен из
«Настроек → Токены MCP».
</p>
<GnInput
v-model="tokenDraft"
label="MCP-токен"
type="password"
placeholder="gnt_…"
spellcheck="false"
/>
<GnButton :disabled="!tokenDraft.trim()" @click="connect">Подключить</GnButton>
<div class="login-links">
<GnButton variant="secondary" size="sm" icon="ph-gear-six" @click="openSettings">
Открыть Настройки сервиса
</GnButton>
<GnButton variant="secondary" size="sm" icon="ph-sign-in" @click="login">
Войти через GNEXUS (сайт)
</GnButton>
</div>
</div>
<template v-else-if="screen === 'ready'">
<div v-if="settingsOpen" class="settings">
<GnInput
v-model="baseDraft"
label="Адрес сервиса"
spellcheck="false"
inputmode="url"
/>
<GnInput
v-model="tokenDraft"
label="MCP-токен"
type="password"
spellcheck="false"
/>
<GnButton
size="sm"
:disabled="!baseDraft.trim() || !tokenDraft.trim()"
@click="connect"
>
Сохранить и подключить
</GnButton>
</div>
<div v-if="userName" class="who muted">{{ userName }}</div>
<GnTabs
class="popup-tabs"
compact
:model-value="tab"
:items="tabs"
@update:model-value="onTab"
/>
<!-- Вкладка добавления -->
<section v-if="tab === 'add'" class="pane">
<GnTextarea
v-model="text"
class="task-text"
:rows="5"
placeholder="Что сделать? Первая строка — название, остальное — описание. Ctrl+Enter — добавить"
@keydown="onKeydown"
/>
<GnSelect
v-model="projectSel"
icon="ph-folders"
:options="projectOptions"
/>
<div class="actions">
<!-- icon-проп: GnButton сам вешает with-icon (иконка в левой полосе кита) -->
<GnButton
icon="ph-paper-plane-tilt"
:loading="busy"
:disabled="!text.trim()"
@click="submit"
>
{{ busy ? 'Добавляю…' : 'Добавить' }}
</GnButton>
<GnButton v-if="success" class="open-service" size="sm" @click="openService">
Открыть GNexus Tasks
</GnButton>
</div>
<transition name="pop">
<div v-if="success" class="done" role="status">
<i class="ph ph-check-circle" aria-hidden="true" />
<span>Задача в стеке</span>
</div>
</transition>
</section>
<!-- Вкладка «В работе»: только просмотр, клик открывает в сервисе -->
<section v-else class="pane">
<div class="list-tools">
<GnIconButton
icon="ph-arrow-clockwise"
size="sm"
label="Обновить"
@click="loadTasks"
/>
</div>
<div v-if="tasksLoading" class="center-row">
<GnLoader circle label="Загружаю задачи" />
</div>
<p v-else-if="tasksError" class="muted">{{ tasksError }}</p>
<p v-else-if="!inWorkTasks.length" class="muted">Задач «В работе» нет.</p>
<ul v-else class="task-list">
<li v-for="t in inWorkTasks" :key="t.id">
<button class="task-row" type="button" @click="openTask(t)">
<span class="task-title">{{ t.title }}</span>
<GnBadge v-if="t.project" variant="neutral">{{ t.project.name }}</GnBadge>
<span v-if="isOverdue(t)" class="overdue">просрочено</span>
<GnBadge v-for="tag in t.tags" :key="t.id + tag.id" variant="neutral">
{{ tag.name }}
</GnBadge>
</button>
</li>
</ul>
</section>
</template>
<div v-else class="login">
<GnLoader circle label="" />
</div>
</div>
</template>
<style scoped>
.popup {
width: 380px;
min-height: 200px;
background: #16161e;
color: #c0caf5;
}
.popup-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
border-bottom: 1px solid #1f2335;
}
.app-name {
font-weight: 600;
margin-right: auto;
}
.who {
padding: 0.25rem 0.75rem 0;
font-size: 0.8rem;
}
.muted {
color: #787c99;
}
.login {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.6rem;
padding: 0.75rem;
}
.login-links {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.4rem;
margin-top: 0.25rem;
}
.settings {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.75rem;
border-bottom: 1px solid #1f2335;
}
.popup-tabs {
margin: 0.5rem 0.75rem 0;
width: auto;
}
.pane {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.center-row {
display: flex;
justify-content: center;
padding: 1.25rem 0;
}
.actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.list-tools {
display: flex;
justify-content: flex-end;
}
.task-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.task-row {
width: 100%;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
text-align: left;
background: #1f2335;
border: 1px solid transparent;
color: inherit;
font: inherit;
padding: 0.45rem 0.6rem;
cursor: pointer;
}
.task-row:hover {
border-color: #414868;
}
.task-title {
margin-right: auto;
}
.overdue {
font-size: 0.75em;
color: #f7768e;
}
.pop-enter-active {
animation: check-pop 0.4s ease-out;
}
.done {
display: flex;
align-items: center;
gap: 0.4rem;
color: #9ece6a;
}
.done i {
font-size: 1.4em;
}
@keyframes check-pop {
0% {
opacity: 0;
transform: scale(0.6);
}
60% {
transform: scale(1.15);
}
100% {
opacity: 1;
transform: scale(1);
}
}
</style>