Newer
Older
gnexus-tasks / extension / src / PopupInner.vue
<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>