<script setup lang="ts">
// Внутренний UI попапа (живёт внутри GnToastProvider в Popup.vue).
// Две вкладки: добавление задачи и просмотр активных.
// Аутентификация — Bearer MCP-токен (кука сервиса из extension-контекста не ходит).
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'
const ACTIVE_STATUSES = new Set(['to_do', 'in_progress', 'deferred'])
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('')
const tab = ref<'add' | 'tasks'>('add')
// — Добавление —
const text = ref('')
const projects = ref<Project[]>([])
const projectId = ref<number | null>(null)
const busy = ref(false)
const success = ref(false)
// — Активные —
const tasks = ref<TaskOut[]>([])
const tasksLoading = ref(false)
const tasksError = ref('')
const activeTasks = computed(() => tasks.value.filter((t) => ACTIVE_STATUSES.has(t.status)))
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 login(): void {
openTab(base.value, '/login')
}
function openSettings(): void {
openTab(base.value, '/settings')
}
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
try {
const res = await createTask(base.value, text.value, projectId.value)
// как 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>
<input
v-model="tokenDraft"
class="input"
spellcheck="false"
placeholder="gnt_…"
type="password"
/>
<GnButton :disabled="!tokenDraft.trim()" @click="connect">Подключить</GnButton>
<button class="linklike" type="button" @click="openSettings">
<i class="ph ph-gear" aria-hidden="true" /> Открыть Настройки сервиса
</button>
<button class="linklike muted" type="button" @click="login">
Войти через GNEXUS (сайт)
</button>
</div>
<template v-else-if="screen === 'ready'">
<div v-if="settingsOpen" class="settings">
<label class="label" for="base-url">Адрес сервиса</label>
<input id="base-url" v-model="baseDraft" class="input" spellcheck="false" />
<label class="label" for="token">MCP-токен</label>
<input id="token" v-model="tokenDraft" class="input" spellcheck="false" type="password" />
<GnButton size="sm" :disabled="!baseDraft.trim() || !tokenDraft.trim()" @click="connect">
Сохранить и подключить
</GnButton>
</div>
<div v-if="userName" class="who muted">{{ userName }}</div>
<nav class="tabs" role="tablist">
<button
class="tab"
:class="{ active: tab === 'add' }"
role="tab"
type="button"
@click="tab = 'add'"
>
<i class="ph ph-plus-circle" aria-hidden="true" /> Добавить
</button>
<button
class="tab"
:class="{ active: tab === 'tasks' }"
role="tab"
type="button"
@click="tab = 'tasks'"
>
<i class="ph ph-list-checks" aria-hidden="true" /> Активные
</button>
</nav>
<!-- Вкладка добавления -->
<section v-if="tab === 'add'" class="pane">
<textarea
v-model="text"
class="task-text"
rows="5"
placeholder="Что сделать? Первая строка — название, остальное — описание. Ctrl+Enter — добавить"
@keydown="onKeydown"
/>
<label class="label" for="project">Проект</label>
<select id="project" v-model="projectId" class="input">
<option :value="null">Без проекта</option>
<option v-for="p in projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
<div class="actions">
<GnButton :disabled="busy || !text.trim()" @click="submit">
<i class="ph ph-paper-plane-tilt" aria-hidden="true" />
{{ 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>
<GnLoader v-if="tasksLoading" class="center" />
<p v-else-if="tasksError" class="muted">{{ tasksError }}</p>
<p v-else-if="!activeTasks.length" class="muted">Активных задач нет.</p>
<ul v-else class="task-list">
<li v-for="t in activeTasks" :key="t.id">
<button class="task-row" type="button" @click="openTask(t)">
<span class="task-title">{{ t.title }}</span>
<span v-if="t.project" class="chip">{{ t.project.name }}</span>
<span v-if="isOverdue(t)" class="overdue">просрочено</span>
<span v-for="tag in t.tags" :key="t.id + tag.id" class="chip muted">{{ tag.name }}</span>
</button>
</li>
</ul>
</section>
</template>
<div v-else class="login">
<GnLoader circle />
</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;
}
.linklike {
background: none;
border: none;
color: #7aa2f7;
cursor: pointer;
font: inherit;
padding: 0.15rem 0;
display: flex;
align-items: center;
gap: 0.4rem;
}
.linklike:hover {
color: #9ece6a;
}
.settings {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.75rem;
border-bottom: 1px solid #1f2335;
}
.label {
font-size: 0.85em;
color: #787c99;
}
.pane {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.task-text {
width: 100%;
resize: vertical;
min-height: 90px;
}
.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;
}
.chip {
font-size: 0.75em;
padding: 0.05rem 0.4rem;
background: #1f2335;
color: #7aa2f7;
}
.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>