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