Compare commits

...

12 Commits

Author SHA1 Message Date
Jonas Plum
a2bdeecb0d feat: scheduler (#1108) 2024-11-04 22:30:20 +01:00
Jonas Plum
42797509f7 fix: set-app-url (#1107) 2024-11-04 20:50:18 +00:00
Jonas Plum
70ba16a6bd feat: docker healthcheck (#1106) 2024-11-04 20:47:55 +00:00
Jonas Plum
f42de34780 fix: ci docker tags 2024-09-30 03:55:39 +02:00
Jonas Plum
88f56a2bdb fix: ci docker login 2024-09-30 03:45:24 +02:00
Jonas Plum
88cc02b350 fix: goreleaser ci permissions (#1105) 2024-09-30 03:30:29 +02:00
Jonas Plum
46f7815699 feat: docker files (#1104) 2024-09-30 03:20:26 +02:00
Jonas Plum
ea03a3ed23 fix: prevent view update (#1102) 2024-09-20 00:02:15 +02:00
Jonas Plum
6346140de5 fix: multiple hooks (#1101) 2024-09-19 23:23:45 +02:00
Jonas Plum
d7bdf1d276 fix: curl example (#1099) 2024-08-13 08:09:46 +02:00
Jonas Plum
1e1022ab15 fix: reaction names (#1098) 2024-08-13 07:44:06 +02:00
Jonas Plum
a2dd6c05e6 feat: mobile ui (#1096) 2024-08-07 22:18:59 +02:00
86 changed files with 963 additions and 1353 deletions

View File

@@ -7,6 +7,8 @@ on:
permissions:
contents: write
id-token: write
packages: write
jobs:
goreleaser:
@@ -21,6 +23,11 @@ jobs:
- run: make build-ui
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: "securitybrewery"
password: ${{ secrets.GITHUB_TOKEN }}
- uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser

View File

@@ -11,6 +11,14 @@ builds:
- linux
- darwin
dockers:
- ids: [ catalyst ]
dockerfile: docker/Dockerfile
image_templates:
- "ghcr.io/securitybrewery/catalyst:main"
- "ghcr.io/securitybrewery/catalyst:latest"
- "ghcr.io/securitybrewery/catalyst:{{.Tag}}"
archives:
- format: tar.gz
# this name template makes the OS and Arch compatible with the results of `uname`.

View File

@@ -39,6 +39,22 @@ build-ui:
cd ui && bun install
cd ui && bun build-only
.PHONY: build
build: build-ui
@echo "Building..."
go build -o catalyst .
.PHONY: build-linux
build-linux: build-ui
@echo "Building..."
GOOS=linux GOARCH=amd64 go build -o catalyst .
.PHONY: docker
docker: build-linux
@echo "Building Docker image..."
docker build -f docker/Dockerfile -t catalyst .
.PHONY: dev
dev:
@echo "Running..."
@@ -57,6 +73,6 @@ dev-10000:
go run . fake-data --users 100 --tickets 10000
go run . serve
.PHONY: dev-ui
.PHONY: serve-ui
serve-ui:
cd ui && bun dev --port 3000

View File

@@ -107,12 +107,7 @@ func setAppURL(app core.App) *cobra.Command {
return
}
settings, err := app.Settings().Clone()
if err != nil {
app.Logger().Error(err.Error())
return
}
settings := app.Settings()
settings.Meta.AppUrl = args[0]

View File

@@ -18,6 +18,13 @@ func addRoutes() func(*core.ServeEvent) error {
return c.Redirect(http.StatusFound, "/ui/")
})
e.Router.GET("/ui/*", staticFiles())
e.Router.GET("/health", func(c echo.Context) error {
if _, err := Flags(e.App); err != nil {
return err
}
return c.String(http.StatusOK, "OK")
})
e.Router.GET("/api/config", func(c echo.Context) error {
flags, err := Flags(e.App)

14
docker/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y curl python3 python3-pip python3-venv
COPY catalyst /usr/local/bin/catalyst
EXPOSE 8080
VOLUME /usr/local/bin/catalyst_data
HEALTHCHECK --interval=5s --timeout=3s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
CMD ["/usr/local/bin/catalyst", "serve", "--http", "0.0.0.0:8080"]

View File

@@ -316,7 +316,7 @@ func reactionRecords(dao *daos.Dao) []*models.Record {
record := models.NewRecord(collection)
record.SetId("w_" + security.PseudorandomString(10))
record.Set("name", "Test Reaction")
record.Set("name", "Alert Ingest Webhook")
record.Set("trigger", "webhook")
record.Set("triggerdata", triggerWebhook)
record.Set("action", "python")
@@ -334,7 +334,7 @@ func reactionRecords(dao *daos.Dao) []*models.Record {
record = models.NewRecord(collection)
record.SetId("w_" + security.PseudorandomString(10))
record.Set("name", "Test Reaction 2")
record.Set("name", "Assign new Tickets")
record.Set("trigger", "hook")
record.Set("triggerdata", triggerHook)
record.Set("action", "python")

1
go.mod
View File

@@ -11,6 +11,7 @@ require (
github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.9.0
github.com/tidwall/sjson v1.2.5
go.uber.org/multierr v1.11.0
)
require (

2
go.sum
View File

@@ -229,6 +229,8 @@ go.opentelemetry.io/otel/metric v1.25.0 h1:LUKbS7ArpFL/I2jJHdJcqMGxkRdxpPHE0VU/D
go.opentelemetry.io/otel/metric v1.25.0/go.mod h1:rkDLUSd2lC5lq2dFNrX9LGAbINP5B7WBkC78RXCpH5s=
go.opentelemetry.io/otel/trace v1.25.0 h1:tqukZGLwQYRIFtSQM2u2+yfMVTgGVeqRLPUYx1Dq6RM=
go.opentelemetry.io/otel/trace v1.25.0/go.mod h1:hCCs70XM/ljO+BeQkyFnbK28SBIJ/Emuha+ccrCRT7I=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
gocloud.dev v0.37.0 h1:XF1rN6R0qZI/9DYjN16Uy0durAmSlf58DHOcb28GPro=
gocloud.dev v0.37.0/go.mod h1:7/O4kqdInCNsc6LqgmuFnS0GRew4XNNYWpA44yQnwco=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=

View File

@@ -0,0 +1,43 @@
package migrations
import (
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/tools/types"
)
const dashboardCountsViewUpdateQuery = `SELECT id, count FROM (
SELECT 'users' as id, COUNT(users.id) as count FROM users
UNION
SELECT 'tickets' as id, COUNT(tickets.id) as count FROM tickets
UNION
SELECT 'tasks' as id, COUNT(tasks.id) as count FROM tasks
UNION
SELECT 'reactions' as id, COUNT(reactions.id) as count FROM reactions
) as counts;`
func dashboardCountsViewUpdateUp(db dbx.Builder) error {
dao := daos.New(db)
collection, err := dao.FindCollectionByNameOrId(dashboardCountsViewName)
if err != nil {
return err
}
collection.Options = types.JsonMap{"query": dashboardCountsViewUpdateQuery}
return dao.SaveCollection(collection)
}
func dashboardCountsViewUpdateDown(db dbx.Builder) error {
dao := daos.New(db)
collection, err := dao.FindCollectionByNameOrId(dashboardCountsViewName)
if err != nil {
return err
}
collection.Options = types.JsonMap{"query": dashboardCountsViewQuery}
return dao.SaveCollection(collection)
}

View File

@@ -0,0 +1,28 @@
package migrations
import (
"fmt"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models/schema"
)
func reactionsUpdateUp(db dbx.Builder) error {
dao := daos.New(db)
triggers := []string{"webhook", "hook", "schedule"}
col, err := dao.FindCollectionByNameOrId(ReactionCollectionName)
if err != nil {
return fmt.Errorf("failed to find collection %s: %w", ReactionCollectionName, err)
}
field := col.Schema.GetFieldByName("trigger")
field.Options = &schema.SelectOptions{MaxSelect: 1, Values: triggers}
col.Schema.AddField(field)
return dao.SaveCollection(col)
}

View File

@@ -12,4 +12,6 @@ func Register() {
migrations.Register(reactionsUp, reactionsDown, "1700000005_reactions.go")
migrations.Register(systemuserUp, systemuserDown, "1700000006_systemuser.go")
migrations.Register(searchViewUp, searchViewDown, "1700000007_search_view.go")
migrations.Register(dashboardCountsViewUpdateUp, dashboardCountsViewUpdateDown, "1700000008_dashboardview.go")
migrations.Register(reactionsUpdateUp, nil, "1700000009_reactions_update.go")
}

View File

@@ -0,0 +1,101 @@
package schedule
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/cron"
"go.uber.org/multierr"
"github.com/SecurityBrewery/catalyst/migrations"
"github.com/SecurityBrewery/catalyst/reaction/action"
)
type Schedule struct {
Expression string `json:"expression"`
}
func Start(pb *pocketbase.PocketBase) {
scheduler := cron.New()
if err := scheduler.Add("reactions", "* * * * *", func() {
ctx := context.Background()
moment := cron.NewMoment(time.Now())
if err := runSchedule(ctx, pb.App, moment); err != nil {
slog.ErrorContext(ctx, fmt.Sprintf("failed to run hook reaction: %v", err))
}
}); err != nil {
slog.Error(fmt.Sprintf("failed to add cron job: %v", err))
}
scheduler.Start()
}
func runSchedule(ctx context.Context, app core.App, moment *cron.Moment) error {
var errs error
records, err := findByScheduleTrigger(app.Dao(), moment)
if err != nil {
errs = multierr.Append(errs, fmt.Errorf("failed to find schedule reaction: %w", err))
}
if len(records) == 0 {
return nil
}
for _, hook := range records {
_, err = action.Run(ctx, app, hook.GetString("action"), hook.GetString("actiondata"), "{}")
if err != nil {
errs = multierr.Append(errs, fmt.Errorf("failed to run hook reaction: %w", err))
}
}
return errs
}
func findByScheduleTrigger(dao *daos.Dao, moment *cron.Moment) ([]*models.Record, error) {
records, err := dao.FindRecordsByExpr(migrations.ReactionCollectionName, dbx.HashExp{"trigger": "schedule"})
if err != nil {
return nil, fmt.Errorf("failed to find schedule reaction: %w", err)
}
if len(records) == 0 {
return nil, nil
}
var errs error
var matchedRecords []*models.Record
for _, record := range records {
var schedule Schedule
if err := json.Unmarshal([]byte(record.GetString("triggerdata")), &schedule); err != nil {
errs = multierr.Append(errs, err)
continue
}
s, err := cron.NewSchedule(schedule.Expression)
if err != nil {
errs = multierr.Append(errs, err)
continue
}
if s.IsDue(moment) {
matchedRecords = append(matchedRecords, record)
}
}
return matchedRecords, errs
}

View File

@@ -3,11 +3,13 @@ package reaction
import (
"github.com/pocketbase/pocketbase"
"github.com/SecurityBrewery/catalyst/reaction/schedule"
"github.com/SecurityBrewery/catalyst/reaction/trigger/hook"
"github.com/SecurityBrewery/catalyst/reaction/trigger/webhook"
)
func BindHooks(pb *pocketbase.PocketBase, test bool) {
schedule.Start(pb)
hook.BindHooks(pb, test)
webhook.BindHooks(pb)
}

View File

@@ -14,6 +14,7 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"go.uber.org/multierr"
"github.com/SecurityBrewery/catalyst/migrations"
"github.com/SecurityBrewery/catalyst/reaction/action"
@@ -70,43 +71,49 @@ func runHook(ctx context.Context, app core.App, collection, event string, record
return fmt.Errorf("failed to marshal webhook payload: %w", err)
}
hook, found, err := findByHookTrigger(app.Dao(), collection, event)
hooks, err := findByHookTrigger(app.Dao(), collection, event)
if err != nil {
return fmt.Errorf("failed to find hook by trigger: %w", err)
}
if !found {
if len(hooks) == 0 {
return nil
}
_, err = action.Run(ctx, app, hook.GetString("action"), hook.GetString("actiondata"), string(payload))
if err != nil {
return fmt.Errorf("failed to run hook reaction: %w", err)
var errs error
for _, hook := range hooks {
_, err = action.Run(ctx, app, hook.GetString("action"), hook.GetString("actiondata"), string(payload))
if err != nil {
errs = multierr.Append(errs, fmt.Errorf("failed to run hook reaction: %w", err))
}
}
return nil
return errs
}
func findByHookTrigger(dao *daos.Dao, collection, event string) (*models.Record, bool, error) {
func findByHookTrigger(dao *daos.Dao, collection, event string) ([]*models.Record, error) {
records, err := dao.FindRecordsByExpr(migrations.ReactionCollectionName, dbx.HashExp{"trigger": "hook"})
if err != nil {
return nil, false, fmt.Errorf("failed to find hook reaction: %w", err)
return nil, fmt.Errorf("failed to find hook reaction: %w", err)
}
if len(records) == 0 {
return nil, false, nil
return nil, nil
}
var matchedRecords []*models.Record
for _, record := range records {
var hook Hook
if err := json.Unmarshal([]byte(record.GetString("triggerdata")), &hook); err != nil {
return nil, false, err
return nil, err
}
if slices.Contains(hook.Collections, collection) && slices.Contains(hook.Events, event) {
return record, true, nil
matchedRecords = append(matchedRecords, record)
}
}
return nil, false, nil
return matchedRecords, nil
}

Binary file not shown.

View File

@@ -7,7 +7,7 @@
<title>Catalyst</title>
</head>
<body>
<div id="app" class="h-screen w-screen"></div>
<div id="app" class="h-screen w-screen overflow-hidden"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -28,6 +28,7 @@
"date-fns": "^3.6.0",
"easymde": "^2.18.0",
"lodash.debounce": "^4.0.8",
"lodash.isequal": "^4.5.0",
"lucide-vue-next": "^0.365.0",
"marked": "^12.0.2",
"pinia": "^2.1.7",
@@ -48,6 +49,7 @@
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@tsconfig/node20": "^20.1.2",
"@types/lodash.debounce": "^4.0.9",
"@types/lodash.isequal": "^4.5.8",
"@types/node": "^20.11.28",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-prettier": "^8.0.0",

View File

@@ -7,17 +7,16 @@ defineProps<{
isPending: boolean
isError: boolean
error: Error | null
value: any
}>()
</script>
<template>
<div v-if="isPending" class="flex justify-center">
<LoaderCircle class="h-16 w-16 animate-spin text-primary" />
<div v-if="isPending" class="flex h-full w-full">
<LoaderCircle class="m-auto h-16 w-16 animate-spin text-primary" />
</div>
<Alert v-else-if="isError" variant="destructive" class="mb-4">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<slot v-else-if="value" />
<slot v-else />
</template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import PanelListElement from '@/components/common/PanelListElement.vue'
import TanView from '@/components/TanView.vue'
import PanelListElement from '@/components/layout/PanelListElement.vue'
import { buttonVariants } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
@@ -32,24 +33,31 @@ const {
<template>
<div class="flex flex-col gap-2">
<Card>
<div v-if="tasks && tasks.length === 0" class="p-2 text-center text-sm text-gray-500">
No open tasks
</div>
<PanelListElement v-else v-for="task in tasks" :key="task.id" class="pr-1">
<span>{{ task.name }}</span>
<RouterLink
:to="{
name: 'tickets',
params: { type: task.expand.ticket.type, id: task.expand.ticket.id }
}"
:class="cn(buttonVariants({ variant: 'outline', size: 'sm' }), 'ml-auto h-8')"
>
<span class="flex flex-row items-center text-sm text-gray-500">
Go to {{ task.expand.ticket.name }}
<ChevronRight class="ml-2 h-4 w-4" />
</span>
</RouterLink>
</PanelListElement>
<TanView :isError="isError" :isPending="isPending" :error="error">
<div v-if="tasks && tasks.length === 0" class="p-2 text-center text-sm text-gray-500">
No open tasks
</div>
<PanelListElement v-else v-for="task in tasks" :key="task.id" class="pr-1">
<span>{{ task.name }}</span>
<RouterLink
:to="{
name: 'tickets',
params: { type: task.expand.ticket.type, id: task.expand.ticket.id }
}"
:class="
cn(
buttonVariants({ variant: 'outline', size: 'sm' }),
'h-8 w-full sm:ml-auto sm:w-auto'
)
"
>
<span class="flex flex-row items-center text-sm text-gray-500">
Go to {{ task.expand.ticket.name }}
<ChevronRight class="ml-2 h-4 w-4" />
</span>
</RouterLink>
</PanelListElement>
</TanView>
</Card>
</div>
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import PanelListElement from '@/components/common/PanelListElement.vue'
import PanelListElement from '@/components/layout/PanelListElement.vue'
import { buttonVariants } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
@@ -42,16 +42,21 @@ const age = (ticket: Ticket) =>
</div>
<PanelListElement v-else v-for="ticket in tickets" :key="ticket.id" class="gap-2 pr-1">
<span>{{ ticket.name }}</span>
<Separator orientation="vertical" class="h-4" />
<Separator orientation="vertical" class="hidden h-4 sm:block" />
<span class="text-sm text-muted-foreground">{{ ticket.expand.type.singular }}</span>
<Separator orientation="vertical" class="h-4" />
<Separator orientation="vertical" class="hidden h-4 sm:block" />
<span class="text-sm text-muted-foreground">Open since {{ age(ticket) }} days</span>
<RouterLink
:to="{
name: 'tickets',
params: { type: ticket.type, id: ticket.id }
}"
:class="cn(buttonVariants({ variant: 'outline', size: 'sm' }), 'ml-auto h-8')"
:class="
cn(
buttonVariants({ variant: 'outline', size: 'sm' }),
'h-8 w-full sm:ml-auto sm:w-auto'
)
"
>
<span class="flex flex-row items-center text-sm text-gray-500">
Go to {{ ticket.name }}

View File

@@ -43,7 +43,7 @@ const ticketsPerWeek = computed(() => {
</script>
<template>
<TanView :isError="isError" :isPending="isPending" :error="error" :value="tickets">
<TanView :isError="isError" :isPending="isPending" :error="error">
<LineChart class="h-40" :data="ticketsPerWeek" index="week" :categories="['count']" />
</TanView>
</template>

View File

@@ -30,7 +30,7 @@ const namedTypes = computed(() => {
</script>
<template>
<TanView :isError="isError" :isPending="isPending" :error="error" :value="namedTypes">
<TanView :isError="isError" :isPending="isPending" :error="error">
<div v-if="namedTypes" class="flex flex-1 items-center">
<DonutChart index="plural" type="donut" category="count" :data="namedTypes" />
</div>

View File

@@ -11,6 +11,7 @@ import {
SelectValue
} from '@/components/ui/select'
import isEqual from 'lodash.isequal'
import { onMounted, ref, watch } from 'vue'
import type { JSONSchema } from '@/lib/types'
@@ -34,6 +35,11 @@ onMounted(() => {
watch(
() => formdata.value,
() => {
const normFormdata = JSON.parse(JSON.stringify(formdata.value))
const normModel = JSON.parse(JSON.stringify(model.value))
if (isEqual(normFormdata, normModel)) return
model.value = { ...formdata.value }
},
{ deep: true }

View File

@@ -3,9 +3,6 @@ import ShortCut from '@/components/ShortCut.vue'
import { ref } from 'vue'
// import { Textarea } from '@/components/ui/textarea'
// import { Input } from '@/components/ui/input'
const model = defineModel({
type: String
})

View File

@@ -0,0 +1,5 @@
<template>
<div class="flex flex-1 items-start justify-start overflow-y-auto overflow-x-hidden">
<slot />
</div>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
defineProps<{
small?: boolean
}>()
</script>
<template>
<div :class="cn('mx-auto flex w-full max-w-[72rem] gap-4 p-4', small && 'max-w-[47rem]')">
<slot />
</div>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { Separator } from '@/components/ui/separator'
import { cn } from '@/lib/utils'
defineProps<{
title?: string
nowrap?: boolean
hideSeparator?: boolean
}>()
</script>
<template>
<div
:class="
cn('flex min-h-14 flex-wrap items-center gap-2 bg-background p-2', nowrap && 'flex-nowrap')
"
>
<h1 v-if="title" class="text-xl font-bold">
{{ title }}
</h1>
<slot />
</div>
<Separator v-if="!hideSeparator" />
</template>

View File

@@ -12,7 +12,7 @@ const props = defineProps<{
<div
:class="
cn(
'flex w-full items-center border-t px-2 py-1 first:rounded-t first:border-none last:rounded-b',
'flex w-full flex-col items-start border-t px-2 py-1 first:rounded-t first:border-none last:rounded-b sm:flex-row sm:items-center',
props.class
)
"

View File

@@ -8,57 +8,78 @@ import { Separator } from '@/components/ui/separator'
import { Menu } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { useCatalystStore } from '@/store/catalyst'
const catalystStore = useCatalystStore()
</script>
<template>
<div class="flex h-[57px] items-center border-b bg-background">
<CatalystLogo
class="size-8"
:class="{ 'flex-1': catalystStore.sidebarCollapsed, 'mx-3': !catalystStore.sidebarCollapsed }"
/>
<h1 class="text-xl font-bold" v-if="!catalystStore.sidebarCollapsed">Catalyst</h1>
</div>
<NavList
:is-collapsed="catalystStore.sidebarCollapsed"
:links="[
{
title: 'Dashboard',
icon: 'PanelsTopLeft',
variant: 'ghost',
to: '/dashboard'
}
]"
/>
<Separator />
<IncidentNav :is-collapsed="catalystStore.sidebarCollapsed" />
<div class="flex-1" />
<Separator />
<NavList
:is-collapsed="catalystStore.sidebarCollapsed"
:links="[
{
title: 'Reactions',
icon: 'Zap',
variant: 'ghost',
to: '/reactions'
}
]"
/>
<Separator />
<UserDropDown :is-collapsed="catalystStore.sidebarCollapsed" />
<Separator />
<Button
variant="ghost"
@click="catalystStore.toggleSidebar()"
size="sm"
class="m-2 justify-start px-3.5"
<div
:class="
cn(
'flex min-w-48 shrink-0 flex-col border-r bg-popover', // transition-all duration-300 ease-in-out',
catalystStore.sidebarCollapsed && 'min-w-[50px]'
)
"
>
<Menu class="size-4" />
<span v-if="!catalystStore.sidebarCollapsed" class="ml-2">Toggle Sidebar</span>
</Button>
<div class="flex h-[57px] items-center border-b bg-background">
<CatalystLogo
class="size-8"
:class="{
'flex-1': catalystStore.sidebarCollapsed,
'mx-3': !catalystStore.sidebarCollapsed
}"
/>
<h1 class="text-xl font-bold" v-if="!catalystStore.sidebarCollapsed">Catalyst</h1>
</div>
<NavList
:is-collapsed="catalystStore.sidebarCollapsed"
:links="[
{
title: 'Dashboard',
icon: 'PanelsTopLeft',
variant: 'ghost',
to: '/dashboard'
}
]"
/>
<Separator />
<IncidentNav :is-collapsed="catalystStore.sidebarCollapsed" />
<div class="flex-1" />
<Separator />
<NavList
:is-collapsed="catalystStore.sidebarCollapsed"
:links="[
{
title: 'Reactions',
icon: 'Zap',
variant: 'ghost',
to: '/reactions'
}
]"
/>
<Separator />
<UserDropDown :is-collapsed="catalystStore.sidebarCollapsed" />
<Separator />
<div :class="cn('flex h-14 items-center px-3', !catalystStore.sidebarCollapsed && 'px-2')">
<Button
variant="ghost"
@click="catalystStore.toggleSidebar()"
size="default"
:class="
cn(
'p-0',
catalystStore.sidebarCollapsed && 'w-9',
!catalystStore.sidebarCollapsed && 'w-full justify-start px-3'
)
"
>
<Menu class="size-4" />
<span v-if="!catalystStore.sidebarCollapsed" class="ml-2">Toggle Sidebar</span>
</Button>
</div>
</div>
</template>

View File

@@ -3,29 +3,37 @@ import SideBar from '@/components/layout/SideBar.vue'
import { TooltipProvider } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useCatalystStore } from '@/store/catalyst'
const catalystStore = useCatalystStore()
defineProps<{
showDetails?: boolean
}>()
</script>
<template>
<TooltipProvider :delay-duration="0">
<div class="flex h-full flex-row items-stretch bg-muted/40">
<SideBar />
<div
:class="
cn(
'flex min-w-48 flex-col border-r bg-popover', // transition-all duration-300 ease-in-out',
catalystStore.sidebarCollapsed && 'min-w-[50px]'
'w-full flex-initial border-r sm:w-72',
!showDetails && 'flex',
showDetails && 'hidden sm:flex'
)
"
>
<SideBar />
<div class="flex h-full w-full flex-col">
<slot name="list" />
</div>
</div>
<div class="w-72 flex-initial border-r">
<slot name="list" />
</div>
<div class="flex-1">
<slot name="single" />
<div
:class="
cn('flex-1 overflow-hidden', !showDetails && 'hidden sm:flex', showDetails && 'flex')
"
>
<div class="flex h-full w-full flex-1 flex-col">
<slot name="single" />
</div>
</div>
</div>
</TooltipProvider>

View File

@@ -1,27 +1,15 @@
<script lang="ts" setup>
import SideBar from '@/components/layout/SideBar.vue'
import { TooltipProvider } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { useCatalystStore } from '@/store/catalyst'
const catalystStore = useCatalystStore()
</script>
<template>
<TooltipProvider :delay-duration="0">
<div class="flex h-full flex-row items-stretch bg-muted/40">
<div
:class="
cn(
'flex min-w-48 flex-col border-r bg-popover', // transition-all duration-300 ease-in-out',
catalystStore.sidebarCollapsed && 'min-w-[50px]'
)
"
>
<SideBar />
<SideBar />
<div class="flex h-full w-full flex-col">
<slot />
</div>
<slot />
</div>
</TooltipProvider>
</template>

View File

@@ -1,11 +1,15 @@
<script setup lang="ts">
import TanView from '@/components/TanView.vue'
import DeleteDialog from '@/components/common/DeleteDialog.vue'
import ColumnBody from '@/components/layout/ColumnBody.vue'
import ColumnBodyContainer from '@/components/layout/ColumnBodyContainer.vue'
import ColumnHeader from '@/components/layout/ColumnHeader.vue'
import ReactionForm from '@/components/reaction/ReactionForm.vue'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast'
import { ChevronLeft } from 'lucide-vue-next'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
@@ -72,28 +76,29 @@ onUnmounted(() => {
</script>
<template>
<TanView :isError="isError" :isPending="isPending" :error="error" :value="reaction">
<div class="flex h-full flex-1 flex-col overflow-hidden">
<div class="flex items-center bg-background px-4 py-2">
<div class="ml-auto">
<DeleteDialog
v-if="reaction"
collection="reactions"
:id="reaction.id"
:name="reaction.name"
:singular="'Reaction'"
:to="{ name: 'reactions' }"
:queryKey="['reactions']"
/>
</div>
<TanView :isError="isError" :isPending="isPending" :error="error">
<ColumnHeader>
<Button @click="router.push({ name: 'reactions' })" variant="outline" class="sm:hidden">
<ChevronLeft class="mr-2 size-4" />
Back
</Button>
<div class="ml-auto">
<DeleteDialog
v-if="reaction"
collection="reactions"
:id="reaction.id"
:name="reaction.name"
:singular="'Reaction'"
:to="{ name: 'reactions' }"
:queryKey="['reactions']"
/>
</div>
<Separator />
</ColumnHeader>
<ScrollArea v-if="reaction" class="flex-1">
<div class="flex max-w-[640px] flex-col gap-4 p-4">
<ReactionForm :reaction="reaction" @submit="updateReactionMutation.mutate" />
</div>
</ScrollArea>
</div>
<ColumnBody v-if="reaction">
<ColumnBodyContainer small>
<ReactionForm :reaction="reaction" @submit="updateReactionMutation.mutate" />
</ColumnBodyContainer>
</ColumnBody>
</TanView>
</template>

View File

@@ -2,6 +2,7 @@
import ActionPythonFormFields from '@/components/reaction/ActionPythonFormFields.vue'
import ActionWebhookFormFields from '@/components/reaction/ActionWebhookFormFields.vue'
import TriggerHookFormFields from '@/components/reaction/TriggerHookFormFields.vue'
import TriggerScheduleFormFields from '@/components/reaction/TriggerScheduleFormFields.vue'
import TriggerWebhookFormFields from '@/components/reaction/TriggerWebhookFormFields.vue'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
@@ -67,6 +68,25 @@ defineRule('required', (value: string) => {
return true
})
defineRule('triggerdata.expression', (value: string) => {
if (values.trigger !== 'schedule') {
return true
}
if (!value) {
return 'This field is required'
}
const macros = ['@yearly', '@annually', '@monthly', '@weekly', '@daily', '@midnight', '@hourly']
if (macros.includes(value)) {
return true
}
const expression =
/^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$/
if (value.match(expression)) {
return true
}
return 'Invalid cron expression'
})
defineRule('triggerdata.token', (value: string) => {
return true
})
@@ -160,6 +180,7 @@ const { handleSubmit, validate, values } = useForm({
validationSchema: {
name: 'required',
trigger: 'required',
'triggerdata.expression': 'triggerdata.expression',
'triggerdata.token': 'triggerdata.token',
'triggerdata.path': 'triggerdata.path',
'triggerdata.collections': 'triggerdata.collections',
@@ -227,7 +248,7 @@ const curlExample = computed(() => {
let cmd = `curl`
if (values.triggerdata.token) {
cmd += ` -H "Auth: Bearer ${values.triggerdata.token}"`
cmd += ` -H "Authorization: Bearer ${values.triggerdata.token}"`
}
if (values.triggerdata.path) {
@@ -239,7 +260,7 @@ const curlExample = computed(() => {
</script>
<template>
<form @submit="onSubmit" class="flex flex-col items-start gap-4">
<form @submit="onSubmit" class="flex w-full flex-col items-start gap-4">
<FormField name="name" v-slot="{ componentField }" validate-on-input>
<FormItem class="w-full">
<FormLabel for="name" class="text-right">Name</FormLabel>
@@ -263,6 +284,7 @@ const curlExample = computed(() => {
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="schedule">Schedule</SelectItem>
<SelectItem value="webhook">HTTP / Webhook</SelectItem>
<SelectItem value="hook">Collection Hook</SelectItem>
</SelectGroup>
@@ -277,7 +299,8 @@ const curlExample = computed(() => {
</FormItem>
</FormField>
<TriggerWebhookFormFields v-if="values.trigger === 'webhook'" />
<TriggerScheduleFormFields v-if="values.trigger === 'schedule'" />
<TriggerWebhookFormFields v-else-if="values.trigger === 'webhook'" />
<TriggerHookFormFields v-else-if="values.trigger === 'hook'" />
<div v-if="values.trigger === 'webhook'">

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import TanView from '@/components/TanView.vue'
import ResourceListElement from '@/components/common/ResourceListElement.vue'
import ColumnHeader from '@/components/layout/ColumnHeader.vue'
import ResourceListElement from '@/components/layout/ResourceListElement.vue'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { onMounted } from 'vue'
@@ -32,7 +32,9 @@ const subtitle = (reaction: Reaction) =>
triggerNiceName(reaction) + ' to ' + reactionNiceName(reaction)
const triggerNiceName = (reaction: Reaction) => {
if (reaction.trigger === 'hook') {
if (reaction.trigger === 'schedule') {
return 'Schedule'
} else if (reaction.trigger === 'hook') {
return 'Collection Hook'
} else if (reaction.trigger === 'webhook') {
return 'HTTP / Webhook'
@@ -63,32 +65,28 @@ onMounted(() => {
</script>
<template>
<TanView :isError="isError" :isPending="isPending" :error="error" :value="reactions">
<div class="flex h-screen flex-col">
<div class="flex items-center bg-background px-4 py-2">
<h1 class="text-xl font-bold">Reactions</h1>
<div class="ml-auto">
<Button variant="ghost" @click="openNew"> New Reaction</Button>
</div>
</div>
<Separator />
<div class="mt-2 flex flex-1 flex-col gap-2 p-4 pt-0">
<TransitionGroup name="list" appear>
<ResourceListElement
v-for="reaction in reactions"
:key="reaction.id"
:title="reaction.name"
:created="reaction.created"
:subtitle="subtitle(reaction)"
description=""
:active="route.params.id === reaction.id"
:to="{ name: 'reactions', params: { id: reaction.id } }"
:open="false"
>
{{ reaction.name }}
</ResourceListElement>
</TransitionGroup>
<TanView :isError="isError" :isPending="isPending" :error="error">
<ColumnHeader title="Reactions">
<div class="ml-auto">
<Button variant="ghost" @click="openNew">New Reaction</Button>
</div>
</ColumnHeader>
<div class="mt-2 flex flex-1 flex-col gap-2 p-2 pt-0">
<TransitionGroup name="list" appear>
<ResourceListElement
v-for="reaction in reactions"
:key="reaction.id"
:title="reaction.name"
:created="reaction.created"
:subtitle="subtitle(reaction)"
description=""
:active="route.params.id === reaction.id"
:to="{ name: 'reactions', params: { id: reaction.id } }"
:open="false"
>
{{ reaction.name }}
</ResourceListElement>
</TransitionGroup>
</div>
</TanView>
</template>

View File

@@ -1,7 +1,11 @@
<script setup lang="ts">
import ColumnBody from '@/components/layout/ColumnBody.vue'
import ColumnBodyContainer from '@/components/layout/ColumnBodyContainer.vue'
import ColumnHeader from '@/components/layout/ColumnHeader.vue'
import ReactionForm from '@/components/reaction/ReactionForm.vue'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { Button } from '@/components/ui/button'
import { ChevronLeft } from 'lucide-vue-next'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { useRouter } from 'vue-router'
@@ -24,14 +28,16 @@ const addReactionMutation = useMutation({
</script>
<template>
<div class="flex h-full flex-1 flex-col overflow-hidden">
<div class="flex min-h-14 items-center bg-background px-4 py-2"></div>
<Separator />
<ColumnHeader>
<Button @click="router.push({ name: 'reactions' })" variant="outline" class="sm:hidden">
<ChevronLeft class="mr-2 size-4" />
Back
</Button>
</ColumnHeader>
<ScrollArea class="flex-1">
<div class="flex max-w-[640px] flex-col gap-4 p-4">
<ReactionForm @submit="addReactionMutation.mutate" />
</div>
</ScrollArea>
</div>
<ColumnBody>
<ColumnBodyContainer small>
<ReactionForm @submit="addReactionMutation.mutate" />
</ColumnBodyContainer>
</ColumnBody>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
</script>
<template>
<FormField name="triggerdata.expression" v-slot="{ componentField }" validate-on-input>
<FormItem>
<FormLabel for="expression" class="text-right"> Cron Expression </FormLabel>
<FormControl>
<Input
id="expression"
class="col-span-3"
v-bind="componentField"
placeholder="Enter a cron expression"
/>
</FormControl>
<FormDescription>
<p class="text-sm text-gray-500">
A cron expression or macro to schedule the trigger. Example: <code>0 * * * *</code> (every
hour) or <code>@daily</code> (every day).
<a
href="https://en.wikipedia.org/wiki/Cron#CRON_expression"
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 underline"
>
Learn more
</a>
</p>
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
</template>

View File

@@ -15,15 +15,11 @@ import { Input } from '@/components/ui/input'
<FormItem>
<FormLabel for="token" class="text-right">Token</FormLabel>
<FormControl>
<Input
id="token"
class="col-span-3"
v-bind="componentField"
placeholder="Enter a token (e.g. 'xyz...')"
/>
<Input id="token" class="col-span-3" v-bind="componentField" placeholder="Enter a token" />
</FormControl>
<FormDescription>
Optional. Include an authorization token in the request headers.
Optional. Include an authorization token in the request headers. Example:
<code>Bearer YOUR_TOKEN</code>
</FormDescription>
<FormMessage />
</FormItem>
@@ -33,14 +29,11 @@ import { Input } from '@/components/ui/input'
<FormItem>
<FormLabel for="path" class="text-right">Path</FormLabel>
<FormControl>
<Input
id="path"
class="col-span-3"
v-bind="componentField"
placeholder="Enter a path (e.g. 'action1')"
/>
<Input id="path" class="col-span-3" v-bind="componentField" placeholder="Enter a path" />
</FormControl>
<FormDescription> Specify the path to trigger the reaction. </FormDescription>
<FormDescription>
Specify the path to trigger the reaction. Example: <code>action1</code>
</FormDescription>
<FormMessage />
</FormItem>
</FormField>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import Icon from '@/components/Icon.vue'
import DeleteDialog from '@/components/common/DeleteDialog.vue'
import ColumnHeader from '@/components/layout/ColumnHeader.vue'
import TicketCloseDialog from '@/components/ticket/TicketCloseDialog.vue'
import TicketUserSelect from '@/components/ticket/TicketUserSelect.vue'
import { Button } from '@/components/ui/button'
@@ -12,7 +13,7 @@ import {
} from '@/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Check, CircleDot, Repeat } from 'lucide-vue-next'
import { Check, ChevronLeft, CircleDot, Repeat } from 'lucide-vue-next'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
@@ -49,7 +50,7 @@ const changeTypeMutation = useMutation({
}),
onSuccess: (data: Ticket) => {
queryClient.invalidateQueries({ queryKey: ['tickets'] })
router.push({ name: 'tickets', params: { type: data.type, id: props.ticket.id } })
// router.push({ name: 'tickets', params: { type: data.type, id: props.ticket.id } })
},
onError: handleError
})
@@ -74,74 +75,81 @@ const closeTicketDialogOpen = ref(false)
</script>
<template>
<div class="flex items-center justify-between bg-background p-2">
<div class="flex items-center gap-2">
<Tooltip>
<TooltipTrigger as-child>
<div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" :disabled="!ticket">
<Icon :name="ticket.expand.type.icon" class="mr-2 size-4" />
{{ ticket.expand.type.singular }}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
v-for="type in otherTypes"
:key="type.id"
class="cursor-pointer"
@click="changeTypeMutation.mutate(type.id)"
>
<Icon :name="type.icon" class="mr-2 size-4" />
Convert to {{ type.singular }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TooltipTrigger>
<TooltipContent>Change Type</TooltipContent>
</Tooltip>
<TicketCloseDialog v-model="closeTicketDialogOpen" :ticket="ticket" />
<Tooltip>
<TooltipTrigger as-child>
<div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" :disabled="!ticket">
<CircleDot v-if="ticket.open" class="mr-2 h-4 w-4" />
<Check v-else class="mr-2 h-4 w-4" />
{{ ticket?.open ? 'Open' : 'Closed' }}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
v-if="ticket.open"
class="cursor-pointer"
@click="closeTicketDialogOpen = true"
>
<Check class="mr-2 size-4" />
Close Ticket
</DropdownMenuItem>
<DropdownMenuItem v-else class="cursor-pointer" @click="closeTicketMutation.mutate">
<Repeat class="mr-2 size-4" />
Reopen Ticket
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TooltipTrigger>
<TooltipContent>Change Status</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<div>
<TicketUserSelect :key="ticket.owner" :uID="ticket.owner" :ticket="ticket" />
</div>
</TooltipTrigger>
<TooltipContent>Change User</TooltipContent>
</Tooltip>
</div>
<ColumnHeader>
<Button
@click="router.push({ name: 'tickets', params: { type: ticket.type } })"
variant="outline"
class="sm:hidden"
>
<ChevronLeft class="mr-2 size-4" />
Back
</Button>
<Tooltip>
<TooltipTrigger as-child>
<div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" :disabled="!ticket">
<Icon :name="ticket.expand.type.icon" class="mr-2 size-4" />
{{ ticket.expand.type.singular }}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
v-for="type in otherTypes"
:key="type.id"
class="cursor-pointer"
@click="changeTypeMutation.mutate(type.id)"
>
<Icon :name="type.icon" class="mr-2 size-4" />
Convert to {{ type.singular }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TooltipTrigger>
<TooltipContent>Change Type</TooltipContent>
</Tooltip>
<TicketCloseDialog v-model="closeTicketDialogOpen" :ticket="ticket" />
<Tooltip>
<TooltipTrigger as-child>
<div>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" :disabled="!ticket">
<CircleDot v-if="ticket.open" class="mr-2 h-4 w-4" />
<Check v-else class="mr-2 h-4 w-4" />
{{ ticket?.open ? 'Open' : 'Closed' }}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
v-if="ticket.open"
class="cursor-pointer"
@click="closeTicketDialogOpen = true"
>
<Check class="mr-2 size-4" />
Close Ticket
</DropdownMenuItem>
<DropdownMenuItem v-else class="cursor-pointer" @click="closeTicketMutation.mutate">
<Repeat class="mr-2 size-4" />
Reopen Ticket
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TooltipTrigger>
<TooltipContent>Change Status</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<div>
<TicketUserSelect :key="ticket.owner" :uID="ticket.owner" :ticket="ticket" />
</div>
</TooltipTrigger>
<TooltipContent>Change User</TooltipContent>
</Tooltip>
<div class="-mx-1 flex-1" />
<DeleteDialog
v-if="ticket"
:collection="'tickets'"
@@ -151,5 +159,5 @@ const closeTicketDialogOpen = ref(false)
:to="{ name: 'tickets' }"
:queryKey="['tickets']"
/>
</div>
</ColumnHeader>
</template>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import ColumnHeader from '@/components/layout/ColumnHeader.vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -38,7 +39,7 @@ const closeButtonDisabled = false // computed(() => !props.ticket.open || messag
</script>
<template>
<div class="flex items-center justify-between gap-2 bg-background p-2">
<ColumnHeader nowrap hideSeparator>
<Input v-if="ticket.open" v-model="resolution" placeholder="Closing reason" />
<div v-else class="flex-1">
<p class="ml-2 text-gray-500">Closed: {{ ticket.resolution }}</p>
@@ -56,5 +57,5 @@ const closeButtonDisabled = false // computed(() => !props.ticket.open || messag
: 'Reopen ' + props.ticket.expand.type.singular
}}
</Button>
</div>
</ColumnHeader>
</template>

View File

@@ -2,6 +2,8 @@
import TanView from '@/components/TanView.vue'
import JSONSchemaFormFields from '@/components/form/JSONSchemaFormFields.vue'
import DynamicMDEditor from '@/components/input/DynamicMDEditor.vue'
import ColumnBody from '@/components/layout/ColumnBody.vue'
import ColumnBodyContainer from '@/components/layout/ColumnBodyContainer.vue'
import StatusIcon from '@/components/ticket/StatusIcon.vue'
import TicketActionBar from '@/components/ticket/TicketActionBar.vue'
import TicketCloseBar from '@/components/ticket/TicketCloseBar.vue'
@@ -15,14 +17,13 @@ import TicketTimeline from '@/components/ticket/timeline/TicketTimeline.vue'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Edit } from 'lucide-vue-next'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { pb } from '@/lib/pocketbase'
@@ -100,87 +101,97 @@ const updateDescription = (value: string) => (message.value = value)
</script>
<template>
<TanView :isError="isError" :isPending="isPending" :error="error" :value="ticket">
<div v-if="ticket" class="flex h-full flex-col">
<TicketActionBar :ticket="ticket" />
<Separator />
<div class="flex w-full max-w-7xl flex-1 flex-col overflow-hidden xl:m-auto xl:flex-row">
<div class="flex flex-1 flex-col gap-4 px-4 pt-4">
<TicketHeader :ticket="ticket" />
<Card class="relative p-4">
<Button v-if="!editMode" variant="outline" class="float-right h-8 gap-2" @click="edit">
<Edit class="h-3.5 w-3.5" />
<span>Edit</span>
</Button>
<DynamicMDEditor
:modelValue="ticket.description"
@update:modelValue="updateDescription"
v-model:edit="editMode"
autofocus
placeholder="Type a description..."
@save="editDescriptionMutation.mutate"
class="min-h-14"
/>
</Card>
<Separator />
<Tabs default-value="timeline" class="flex flex-1 flex-col overflow-hidden">
<TabsList>
<TabsTrigger value="timeline">
Timeline
<Badge
v-if="
ticket.expand.timeline_via_ticket &&
ticket.expand.timeline_via_ticket.length > 0
"
variant="outline"
class="ml-2"
>
{{
ticket.expand.timeline_via_ticket ? ticket.expand.timeline_via_ticket.length : 0
}}
</Badge>
</TabsTrigger>
<TabsTrigger value="tasks">
Tasks
<Badge
v-if="ticket.expand.tasks_via_ticket && ticket.expand.tasks_via_ticket.length > 0"
variant="outline"
class="ml-2"
>
{{ ticket.expand.tasks_via_ticket ? ticket.expand.tasks_via_ticket.length : 0 }}
<StatusIcon :status="taskStatus" class="size-6" />
</Badge>
</TabsTrigger>
<TabsTrigger value="comments">
Comments
<Badge
v-if="
ticket.expand.comments_via_ticket &&
ticket.expand.comments_via_ticket.length > 0
"
variant="outline"
class="ml-2"
>
{{
ticket.expand.comments_via_ticket ? ticket.expand.comments_via_ticket.length : 0
}}
</Badge>
</TabsTrigger>
</TabsList>
<TicketTab value="timeline">
<TicketTimeline :ticket="ticket" :timeline="ticket.expand.timeline_via_ticket" />
</TicketTab>
<TicketTab value="tasks">
<TicketTasks :ticket="ticket" :tasks="ticket.expand.tasks_via_ticket" />
</TicketTab>
<TicketTab value="comments">
<TicketComments :ticket="ticket" :comments="ticket.expand.comments_via_ticket" />
</TicketTab>
</Tabs>
<Separator class="xl:hidden" />
</div>
<ScrollArea>
<div class="flex flex-initial flex-col gap-4 p-4 xl:w-96">
<TanView :isError="isError" :isPending="isPending" :error="error">
<template v-if="ticket">
<TicketActionBar :ticket="ticket" class="shrink-0" />
<ColumnBody>
<ColumnBodyContainer class="flex-col gap-4 xl:flex-row">
<div class="flex flex-1 flex-col gap-4">
<TicketHeader :ticket="ticket" />
<Card class="relative p-4">
<Button
v-if="!editMode"
variant="outline"
class="float-right h-8 gap-2"
@click="edit"
>
<Edit class="h-3.5 w-3.5" />
<span>Edit</span>
</Button>
<DynamicMDEditor
:modelValue="ticket.description"
@update:modelValue="updateDescription"
v-model:edit="editMode"
autofocus
placeholder="Type a description..."
@save="editDescriptionMutation.mutate"
class="min-h-14"
/>
</Card>
<Separator />
<Tabs default-value="timeline" class="flex flex-1 flex-col">
<TabsList>
<TabsTrigger value="timeline">
Timeline
<Badge
v-if="
ticket.expand.timeline_via_ticket &&
ticket.expand.timeline_via_ticket.length > 0
"
variant="outline"
class="ml-2 hidden sm:inline-flex"
>
{{
ticket.expand.timeline_via_ticket
? ticket.expand.timeline_via_ticket.length
: 0
}}
</Badge>
</TabsTrigger>
<TabsTrigger value="tasks">
Tasks
<Badge
v-if="
ticket.expand.tasks_via_ticket && ticket.expand.tasks_via_ticket.length > 0
"
variant="outline"
class="ml-2 hidden sm:inline-flex"
>
{{ ticket.expand.tasks_via_ticket ? ticket.expand.tasks_via_ticket.length : 0 }}
<StatusIcon :status="taskStatus" class="size-6" />
</Badge>
</TabsTrigger>
<TabsTrigger value="comments">
Comments
<Badge
v-if="
ticket.expand.comments_via_ticket &&
ticket.expand.comments_via_ticket.length > 0
"
variant="outline"
class="ml-2 hidden sm:inline-flex"
>
{{
ticket.expand.comments_via_ticket
? ticket.expand.comments_via_ticket.length
: 0
}}
</Badge>
</TabsTrigger>
</TabsList>
<TicketTab value="timeline">
<TicketTimeline :ticket="ticket" :timeline="ticket.expand.timeline_via_ticket" />
</TicketTab>
<TicketTab value="tasks">
<TicketTasks :ticket="ticket" :tasks="ticket.expand.tasks_via_ticket" />
</TicketTab>
<TicketTab value="comments">
<TicketComments :ticket="ticket" :comments="ticket.expand.comments_via_ticket" />
</TicketTab>
</Tabs>
<Separator class="xl:hidden" />
</div>
<div class="flex flex-col gap-4 xl:w-96 xl:flex-initial">
<div>
<div class="flex h-10 flex-row items-center justify-between text-muted-foreground">
<span class="text-sm font-semibold"> Details </span>
@@ -196,10 +207,10 @@ const updateDescription = (value: string) => (message.value = value)
<Separator />
<TicketFiles :ticket="ticket" :files="ticket.expand.files_via_ticket" />
</div>
</ScrollArea>
</div>
</ColumnBodyContainer>
</ColumnBody>
<Separator />
<TicketCloseBar :ticket="ticket" />
</div>
<TicketCloseBar :ticket="ticket" class="shrink-0" />
</template>
</TanView>
</template>

View File

@@ -38,13 +38,13 @@ const updateName = (value: string) => {
<DynamicInput :modelValue="ticket.name" @update:modelValue="updateName" class="-mx-1" />
</span>
<div class="flex flex-row space-x-2 px-1 text-xs">
<div class="flex items-center gap-1 text-muted-foreground">
<div class="flex flex-col items-stretch gap-1 text-xs text-muted-foreground md:h-4 md:flex-row">
<div>
Created:
{{ format(new Date(ticket.created), 'PPpp') }}
</div>
<Separator orientation="vertical" />
<div class="flex items-center gap-1 text-muted-foreground">
<Separator orientation="vertical" class="hidden md:block" />
<div>
Updated:
{{ format(new Date(ticket.updated), 'PPpp') }}
</div>

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup>
import ColumnHeader from '@/components/layout/ColumnHeader.vue'
import TicketListList from '@/components/ticket/TicketListList.vue'
import TicketNewDialog from '@/components/ticket/TicketNewDialog.vue'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
@@ -14,7 +15,6 @@ import {
PaginationNext,
PaginationPrev
} from '@/components/ui/pagination'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
@@ -106,7 +106,7 @@ watch(
if (!route.params.id && ticketItems.value && ticketItems.value.items.length > 0) {
router.push({
name: 'tickets',
params: { type: props.selectedType.id, id: ticketItems.value.items[0].id }
params: { type: props.selectedType.id }
})
}
}
@@ -121,90 +121,81 @@ watch([tab, props.selectedType, page, perPage], () => refetch())
</script>
<template>
<div class="flex h-screen flex-col">
<div class="flex items-center bg-background px-4 py-2">
<h1 class="text-xl font-bold">
{{ selectedType?.plural }}
</h1>
<div class="ml-auto">
<TicketNewDialog :selectedType="selectedType" />
</div>
<ColumnHeader :title="selectedType?.plural">
<div class="ml-auto">
<TicketNewDialog :selectedType="selectedType" />
</div>
</ColumnHeader>
<Tabs v-model="tab" class="flex flex-1 flex-col overflow-hidden">
<div class="flex items-center justify-between px-2 pt-2">
<TabsList>
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="open">Open</TabsTrigger>
<TabsTrigger value="closed">Closed</TabsTrigger>
</TabsList>
<!-- Button variant="outline" size="sm" class="h-7 gap-1 rounded-md px-3">
<ListFilter class="h-3.5 w-3.5" />
<span class="sr-only sm:not-sr-only">Filter</span>
</Button-->
</div>
<div class="p-2">
<form>
<div class="relative flex flex-row items-center">
<Input v-model="searchValue" placeholder="Search" @keydown.enter.prevent class="pl-8" />
<span class="absolute inset-y-0 start-0 flex items-center justify-center px-2">
<Search class="size-4 text-muted-foreground" />
</span>
</div>
</form>
</div>
<Separator />
<Tabs v-model="tab" class="flex flex-1 flex-col overflow-hidden">
<div class="flex items-center justify-between px-4 pt-2">
<TabsList>
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="open">Open</TabsTrigger>
<TabsTrigger value="closed">Closed</TabsTrigger>
</TabsList>
<!-- Button variant="outline" size="sm" class="h-7 gap-1 rounded-md px-3">
<ListFilter class="h-3.5 w-3.5" />
<span class="sr-only sm:not-sr-only">Filter</span>
</Button-->
</div>
<div class="px-4 py-2">
<form>
<div class="relative flex flex-row items-center">
<Input v-model="searchValue" placeholder="Search" @keydown.enter.prevent class="pl-8" />
<span class="absolute inset-y-0 start-0 flex items-center justify-center px-2">
<Search class="size-4 text-muted-foreground" />
</span>
</div>
</form>
</div>
<Separator />
<div v-if="isPending" class="flex h-full w-full items-center justify-center">
<LoaderCircle class="h-16 w-16 animate-spin text-primary" />
</div>
<Alert v-else-if="isError" variant="destructive" class="mb-4 h-screen w-screen">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<ScrollArea v-else-if="ticketItems" class="flex-1">
<TicketListList :tickets="ticketItems.items" />
</ScrollArea>
<Separator />
<div class="my-2 flex items-center justify-center">
<span class="text-xs text-muted-foreground">
{{ ticketItems ? ticketItems.items.length : '?' }} of
{{ ticketItems ? ticketItems.totalItems : '?' }} tickets
</span>
</div>
<div class="mb-4 flex items-center justify-center">
<Pagination
v-slot="{ page }"
:total="ticketItems ? ticketItems.totalItems : 0"
:itemsPerPage="perPage"
:sibling-count="0"
:default-page="1"
@update:page="page = $event"
>
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
<PaginationFirst />
<PaginationPrev />
<div v-if="isPending" class="flex h-full w-full items-center justify-center">
<LoaderCircle class="h-16 w-16 animate-spin text-primary" />
</div>
<Alert v-else-if="isError" variant="destructive" class="mb-2 h-screen w-screen">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<div v-else-if="ticketItems" class="flex-1 overflow-y-auto overflow-x-hidden">
<TicketListList :tickets="ticketItems.items" />
</div>
<Separator />
<div class="my-2 flex items-center justify-center">
<span class="text-xs text-muted-foreground">
{{ ticketItems ? ticketItems.items.length : '?' }} of
{{ ticketItems ? ticketItems.totalItems : '?' }} tickets
</span>
</div>
<div class="mb-2 flex items-center justify-center">
<Pagination
v-slot="{ page }"
:total="ticketItems ? ticketItems.totalItems : 0"
:itemsPerPage="perPage"
:sibling-count="0"
:default-page="1"
@update:page="page = $event"
>
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
<PaginationFirst />
<PaginationPrev />
<template v-for="(item, index) in items">
<PaginationListItem
v-if="item.type === 'page'"
:key="index"
:value="item.value"
as-child
>
<Button
class="h-10 w-10 p-0"
:variant="item.value === page ? 'default' : 'outline'"
>
{{ item.value }}
</Button>
</PaginationListItem>
<PaginationEllipsis v-else :key="item.type" :index="index" />
</template>
<PaginationNext />
<PaginationLast />
</PaginationList>
</Pagination>
</div>
</Tabs>
</div>
<template v-for="(item, index) in items">
<PaginationListItem
v-if="item.type === 'page'"
:key="index"
:value="item.value"
as-child
>
<Button class="h-10 w-10 p-0" :variant="item.value === page ? 'default' : 'outline'">
{{ item.value }}
</Button>
</PaginationListItem>
<PaginationEllipsis v-else :key="item.type" :index="index" />
</template>
<PaginationNext />
<PaginationLast />
</PaginationList>
</Pagination>
</div>
</Tabs>
</template>

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup>
import ResourceListElement from '@/components/common/ResourceListElement.vue'
import ResourceListElement from '@/components/layout/ResourceListElement.vue'
import { useRoute } from 'vue-router'
@@ -13,7 +13,7 @@ defineProps<{
</script>
<template>
<div class="mt-2 flex w-full flex-1 flex-col gap-2 p-4 pt-0">
<div class="mt-2 flex w-full flex-1 flex-col gap-2 p-2 pt-0">
<ResourceListElement
v-for="item of tickets"
:key="item.id"

View File

@@ -1,5 +1,4 @@
<script setup lang="ts">
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { TabsContent } from '@/components/ui/tabs'
@@ -12,10 +11,8 @@ defineProps<{
<TabsContent :value="value" class="flex-1 overflow-hidden">
<div class="flex h-full flex-col overflow-hidden">
<Separator class="mt-2" />
<ScrollArea class="flex-1">
<slot />
<div class="h-4" />
</ScrollArea>
<slot />
<div class="h-4" />
</div>
</TabsContent>
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import DeleteDialog from '@/components/common/DeleteDialog.vue'
import PanelListElement from '@/components/common/PanelListElement.vue'
import PanelListElement from '@/components/layout/PanelListElement.vue'
import TicketPanel from '@/components/ticket/TicketPanel.vue'
import LinkAddDialog from '@/components/ticket/link/LinkAddDialog.vue'
import { Button } from '@/components/ui/button'
@@ -28,7 +28,12 @@ const dialogOpen = ref(false)
>
No links added yet.
</div>
<PanelListElement v-for="link in links" :key="link.id" :title="link.url" class="pr-1">
<PanelListElement
v-for="link in links"
:key="link.id"
:title="link.url"
class="flex-row items-center pr-1"
>
<a :href="link.url" target="_blank" class="flex flex-1 items-center overflow-hidden">
<span class="mr-2 text-blue-500 underline">
{{ link.name }}

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import DeleteDialog from '@/components/common/DeleteDialog.vue'
import PanelListElement from '@/components/common/PanelListElement.vue'
import UserSelect from '@/components/common/UserSelect.vue'
import DynamicInput from '@/components/input/DynamicInput.vue'
import PanelListElement from '@/components/layout/PanelListElement.vue'
import TaskAddDialog from '@/components/ticket/task/TaskAddDialog.vue'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
@@ -67,12 +67,14 @@ const updateTaskName = (id: string, name: string) => updateTaskNameMutation.muta
</Card>
<Card v-else>
<PanelListElement v-for="task in tasks" :key="task.id" class="pr-1">
<Checkbox :checked="!task.open" class="mr-2" @click="check(task)" />
<DynamicInput
:modelValue="task.name"
@update:modelValue="updateTaskName(task.id, $event)"
class="mr-2 flex-1"
/>
<div class="flex flex-row items-center">
<Checkbox :checked="!task.open" class="mr-2" @click="check(task)" />
<DynamicInput
:modelValue="task.name"
@update:modelValue="updateTaskName(task.id, $event)"
class="mr-2 flex-1"
/>
</div>
<div class="ml-auto flex items-center">
<UserSelect v-if="!task.expand.owner" @update:modelValue="update(task.id, $event)">
<Button variant="outline" role="combobox" class="h-8">

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import PanelListElement from '@/components/common/PanelListElement.vue'
import DynamicMDEditor from '@/components/input/DynamicMDEditor.vue'
import PanelListElement from '@/components/layout/PanelListElement.vue'
import { Button } from '@/components/ui/button'
import {
Dialog,

View File

@@ -1,19 +0,0 @@
<script setup lang="ts">
import {
AccordionRoot,
type AccordionRootEmits,
type AccordionRootProps,
useForwardPropsEmits
} from 'radix-vue'
const props = defineProps<AccordionRootProps>()
const emits = defineEmits<AccordionRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AccordionRoot v-bind="forwarded">
<slot />
</AccordionRoot>
</template>

View File

@@ -1,25 +0,0 @@
<script setup lang="ts">
import { AccordionContent, type AccordionContentProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AccordionContent
v-bind="delegatedProps"
class="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
>
<div :class="cn('pb-4 pt-0', props.class)">
<slot />
</div>
</AccordionContent>
</template>

View File

@@ -1,22 +0,0 @@
<script setup lang="ts">
import { AccordionItem, type AccordionItemProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<AccordionItem v-bind="forwardedProps" :class="cn('border-b', props.class)">
<slot />
</AccordionItem>
</template>

View File

@@ -1,33 +0,0 @@
<script setup lang="ts">
import { AccordionHeader, AccordionTrigger, type AccordionTriggerProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<AccordionHeader class="flex">
<AccordionTrigger
v-bind="delegatedProps"
:class="
cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
props.class
)
"
>
<slot />
<!-- slot name="icon">
<ChevronDown class="h-4 w-4 shrink-0 transition-transform duration-200" />
</slot-->
</AccordionTrigger>
</AccordionHeader>
</template>

View File

@@ -1,4 +0,0 @@
export { default as Accordion } from './Accordion.vue'
export { default as AccordionContent } from './AccordionContent.vue'
export { default as AccordionItem } from './AccordionItem.vue'
export { default as AccordionTrigger } from './AccordionTrigger.vue'

View File

@@ -1,25 +0,0 @@
<script setup lang="ts">
import { type AvatarVariants, avatarVariant } from '.'
import { AvatarRoot } from 'radix-vue'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<{
class?: HTMLAttributes['class']
size?: AvatarVariants['size']
shape?: AvatarVariants['shape']
}>(),
{
size: 'sm',
shape: 'circle'
}
)
</script>
<template>
<AvatarRoot :class="cn(avatarVariant({ size, shape }), props.class)">
<slot />
</AvatarRoot>
</template>

View File

@@ -1,11 +0,0 @@
<script setup lang="ts">
import { AvatarFallback, type AvatarFallbackProps } from 'radix-vue'
const props = defineProps<AvatarFallbackProps>()
</script>
<template>
<AvatarFallback v-bind="props">
<slot />
</AvatarFallback>
</template>

View File

@@ -1,9 +0,0 @@
<script setup lang="ts">
import { AvatarImage, type AvatarImageProps } from 'radix-vue'
const props = defineProps<AvatarImageProps>()
</script>
<template>
<AvatarImage v-bind="props" class="h-full w-full object-cover" />
</template>

View File

@@ -1,24 +0,0 @@
import { type VariantProps, cva } from 'class-variance-authority'
export { default as Avatar } from './Avatar.vue'
export { default as AvatarImage } from './AvatarImage.vue'
export { default as AvatarFallback } from './AvatarFallback.vue'
export const avatarVariant = cva(
'inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden',
{
variants: {
size: {
sm: 'h-10 w-10 text-xs',
base: 'h-16 w-16 text-2xl',
lg: 'h-32 w-32 text-5xl'
},
shape: {
circle: 'rounded-full',
square: 'rounded-md'
}
}
}
)
export type AvatarVariants = VariantProps<typeof avatarVariant>

View File

@@ -1,69 +0,0 @@
<script lang="ts" setup>
import {
CalendarCell,
CalendarCellTrigger,
CalendarGrid,
CalendarGridBody,
CalendarGridHead,
CalendarGridRow,
CalendarHeadCell,
CalendarHeader,
CalendarHeading,
CalendarNextButton,
CalendarPrevButton
} from '.'
import {
CalendarRoot,
type CalendarRootEmits,
type CalendarRootProps,
useForwardPropsEmits
} from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<CalendarRootEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CalendarRoot v-slot="{ grid, weekDays }" :class="cn('p-3', props.class)" v-bind="forwarded">
<CalendarHeader>
<CalendarPrevButton />
<CalendarHeading />
<CalendarNextButton />
</CalendarHeader>
<div class="mt-4 flex flex-col gap-y-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
<CalendarGridHead>
<CalendarGridRow>
<CalendarHeadCell v-for="day in weekDays" :key="day">
{{ day }}
</CalendarHeadCell>
</CalendarGridRow>
</CalendarGridHead>
<CalendarGridBody>
<CalendarGridRow
v-for="(weekDates, index) in month.rows"
:key="`weekDate-${index}`"
class="mt-2 w-full"
>
<CalendarCell v-for="weekDate in weekDates" :key="weekDate.toString()" :date="weekDate">
<CalendarCellTrigger :day="weekDate" :month="month.value" />
</CalendarCell>
</CalendarGridRow>
</CalendarGridBody>
</CalendarGrid>
</div>
</CalendarRoot>
</template>

View File

@@ -1,30 +0,0 @@
<script lang="ts" setup>
import { CalendarCell, type CalendarCellProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarCellProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarCell
:class="
cn(
'relative h-9 w-9 p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-month])]:bg-accent/50',
props.class
)
"
v-bind="forwardedProps"
>
<slot />
</CalendarCell>
</template>

View File

@@ -1,42 +0,0 @@
<script lang="ts" setup>
import { buttonVariants } from '@/components/ui/button'
import { CalendarCellTrigger, type CalendarCellTriggerProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarCellTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarCellTrigger
:class="
cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selected
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
// Disabled
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
// Unavailable
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
// Outside months
'data-[outside-month]:pointer-events-none data-[outside-month]:text-muted-foreground data-[outside-month]:opacity-50 [&[data-outside-month][data-selected]]:bg-accent/50 [&[data-outside-month][data-selected]]:text-muted-foreground [&[data-outside-month][data-selected]]:opacity-30',
props.class
)
"
v-bind="forwardedProps"
>
<slot />
</CalendarCellTrigger>
</template>

View File

@@ -1,25 +0,0 @@
<script lang="ts" setup>
import { CalendarGrid, type CalendarGridProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarGridProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarGrid
:class="cn('w-full border-collapse space-y-1', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarGrid>
</template>

View File

@@ -1,11 +0,0 @@
<script lang="ts" setup>
import { CalendarGridBody, type CalendarGridBodyProps } from 'radix-vue'
const props = defineProps<CalendarGridBodyProps>()
</script>
<template>
<CalendarGridBody v-bind="props">
<slot />
</CalendarGridBody>
</template>

View File

@@ -1,11 +0,0 @@
<script lang="ts" setup>
import { CalendarGridHead, type CalendarGridHeadProps } from 'radix-vue'
const props = defineProps<CalendarGridHeadProps>()
</script>
<template>
<CalendarGridHead v-bind="props">
<slot />
</CalendarGridHead>
</template>

View File

@@ -1,22 +0,0 @@
<script lang="ts" setup>
import { CalendarGridRow, type CalendarGridRowProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarGridRowProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarGridRow :class="cn('flex', props.class)" v-bind="forwardedProps">
<slot />
</CalendarGridRow>
</template>

View File

@@ -1,25 +0,0 @@
<script lang="ts" setup>
import { CalendarHeadCell, type CalendarHeadCellProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarHeadCellProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeadCell
:class="cn('w-9 rounded-md text-[0.8rem] font-normal text-muted-foreground', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarHeadCell>
</template>

View File

@@ -1,25 +0,0 @@
<script lang="ts" setup>
import { CalendarHeader, type CalendarHeaderProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarHeaderProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeader
:class="cn('relative flex w-full items-center justify-between pt-1', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarHeader>
</template>

View File

@@ -1,28 +0,0 @@
<script lang="ts" setup>
import { CalendarHeading, type CalendarHeadingProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarHeadingProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeading
v-slot="{ headingValue }"
:class="cn('text-sm font-medium', props.class)"
v-bind="forwardedProps"
>
<slot :heading-value>
{{ headingValue }}
</slot>
</CalendarHeading>
</template>

View File

@@ -1,37 +0,0 @@
<script lang="ts" setup>
import { buttonVariants } from '@/components/ui/button'
import { ChevronRight } from 'lucide-vue-next'
import { CalendarNext, type CalendarNextProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarNextProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarNext
:class="
cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class
)
"
v-bind="forwardedProps"
>
<slot>
<ChevronRight class="h-4 w-4" />
</slot>
</CalendarNext>
</template>

View File

@@ -1,37 +0,0 @@
<script lang="ts" setup>
import { buttonVariants } from '@/components/ui/button'
import { ChevronLeft } from 'lucide-vue-next'
import { CalendarPrev, type CalendarPrevProps, useForwardProps } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarPrevProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarPrev
:class="
cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class
)
"
v-bind="forwardedProps"
>
<slot>
<ChevronLeft class="h-4 w-4" />
</slot>
</CalendarPrev>
</template>

View File

@@ -1,12 +0,0 @@
export { default as Calendar } from './Calendar.vue'
export { default as CalendarCell } from './CalendarCell.vue'
export { default as CalendarCellTrigger } from './CalendarCellTrigger.vue'
export { default as CalendarGrid } from './CalendarGrid.vue'
export { default as CalendarGridBody } from './CalendarGridBody.vue'
export { default as CalendarGridHead } from './CalendarGridHead.vue'
export { default as CalendarGridRow } from './CalendarGridRow.vue'
export { default as CalendarHeadCell } from './CalendarHeadCell.vue'
export { default as CalendarHeader } from './CalendarHeader.vue'
export { default as CalendarHeading } from './CalendarHeading.vue'
export { default as CalendarNextButton } from './CalendarNextButton.vue'
export { default as CalendarPrevButton } from './CalendarPrevButton.vue'

View File

@@ -1,15 +0,0 @@
<script setup lang="ts">
import { CollapsibleRoot, useForwardPropsEmits } from 'radix-vue'
import type { CollapsibleRootEmits, CollapsibleRootProps } from 'radix-vue'
const props = defineProps<CollapsibleRootProps>()
const emits = defineEmits<CollapsibleRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<CollapsibleRoot v-slot="{ open }" v-bind="forwarded">
<slot :open="open" />
</CollapsibleRoot>
</template>

View File

@@ -1,14 +0,0 @@
<script setup lang="ts">
import { CollapsibleContent, type CollapsibleContentProps } from 'radix-vue'
const props = defineProps<CollapsibleContentProps>()
</script>
<template>
<CollapsibleContent
v-bind="props"
class="overflow-hidden transition-all data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down"
>
<slot />
</CollapsibleContent>
</template>

View File

@@ -1,11 +0,0 @@
<script setup lang="ts">
import { CollapsibleTrigger, type CollapsibleTriggerProps } from 'radix-vue'
const props = defineProps<CollapsibleTriggerProps>()
</script>
<template>
<CollapsibleTrigger v-bind="props">
<slot />
</CollapsibleTrigger>
</template>

View File

@@ -1,3 +0,0 @@
export { default as Collapsible } from './Collapsible.vue'
export { default as CollapsibleTrigger } from './CollapsibleTrigger.vue'
export { default as CollapsibleContent } from './CollapsibleContent.vue'

View File

@@ -1,43 +0,0 @@
<script setup lang="ts">
import { GripVertical } from 'lucide-vue-next'
import {
SplitterResizeHandle,
type SplitterResizeHandleEmits,
type SplitterResizeHandleProps,
useForwardPropsEmits
} from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<
SplitterResizeHandleProps & { class?: HTMLAttributes['class']; withHandle?: boolean }
>()
const emits = defineEmits<SplitterResizeHandleEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SplitterResizeHandle
v-bind="forwarded"
:class="
cn(
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 [&[data-orientation=vertical]>div]:rotate-90 [&[data-orientation=vertical]]:h-px [&[data-orientation=vertical]]:w-full [&[data-orientation=vertical]]:after:left-0 [&[data-orientation=vertical]]:after:h-1 [&[data-orientation=vertical]]:after:w-full [&[data-orientation=vertical]]:after:-translate-y-1/2 [&[data-orientation=vertical]]:after:translate-x-0',
props.class
)
"
>
<template v-if="props.withHandle">
<div class="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical class="h-2.5 w-2.5" />
</div>
</template>
</SplitterResizeHandle>
</template>

View File

@@ -1,30 +0,0 @@
<script setup lang="ts">
import {
SplitterGroup,
type SplitterGroupEmits,
type SplitterGroupProps,
useForwardPropsEmits
} from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<SplitterGroupProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<SplitterGroupEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SplitterGroup
v-bind="forwarded"
:class="cn('flex h-full w-full data-[panel-group-direction=vertical]:flex-col', props.class)"
>
<slot />
</SplitterGroup>
</template>

View File

@@ -1,3 +0,0 @@
export { default as ResizablePanelGroup } from './ResizablePanelGroup.vue'
export { default as ResizableHandle } from './ResizableHandle.vue'
export { SplitterPanel as ResizablePanel } from 'radix-vue'

View File

@@ -1,30 +0,0 @@
<script setup lang="ts">
import ScrollBar from './ScrollBar.vue'
import {
ScrollAreaCorner,
ScrollAreaRoot,
type ScrollAreaRootProps,
ScrollAreaViewport
} from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<ScrollAreaRootProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<ScrollAreaRoot v-bind="delegatedProps" :class="cn('relative overflow-hidden', props.class)">
<ScrollAreaViewport class="h-full w-full rounded-[inherit]">
<slot />
</ScrollAreaViewport>
<ScrollBar />
<ScrollAreaCorner />
</ScrollAreaRoot>
</template>

View File

@@ -1,35 +0,0 @@
<script setup lang="ts">
import { ScrollAreaScrollbar, type ScrollAreaScrollbarProps, ScrollAreaThumb } from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<ScrollAreaScrollbarProps & { class?: HTMLAttributes['class'] }>(),
{
orientation: 'vertical'
}
)
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
</script>
<template>
<ScrollAreaScrollbar
v-bind="delegatedProps"
:class="
cn(
'flex touch-none select-none transition-colors',
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent p-px',
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent p-px',
props.class
)
"
>
<ScrollAreaThumb class="relative flex-1 rounded-full bg-border" />
</ScrollAreaScrollbar>
</template>

View File

@@ -1,2 +0,0 @@
export { default as ScrollArea } from './ScrollArea.vue'
export { default as ScrollBar } from './ScrollBar.vue'

View File

@@ -1,44 +0,0 @@
<script setup lang="ts">
import {
SwitchRoot,
type SwitchRootEmits,
type SwitchRootProps,
SwitchThumb,
useForwardPropsEmits
} from 'radix-vue'
import { type HTMLAttributes, computed } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<SwitchRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<SwitchRootEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SwitchRoot
v-bind="forwarded"
:class="
cn(
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
props.class
)
"
>
<SwitchThumb
:class="
cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
)
"
/>
</SwitchRoot>
</template>

View File

@@ -1 +0,0 @@
export { default as Switch } from './Switch.vue'

View File

@@ -3,10 +3,11 @@ import OpenTasks from '@/components/dashboard/OpenTasks.vue'
import OpenTickets from '@/components/dashboard/OpenTickets.vue'
import TicketOverTime from '@/components/dashboard/TicketOverTime.vue'
import TicketTypes from '@/components/dashboard/TicketTypes.vue'
import ColumnBody from '@/components/layout/ColumnBody.vue'
import ColumnBodyContainer from '@/components/layout/ColumnBodyContainer.vue'
import ColumnHeader from '@/components/layout/ColumnHeader.vue'
import TwoColumn from '@/components/layout/TwoColumn.vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { ExternalLink } from 'lucide-vue-next'
@@ -46,96 +47,91 @@ onMounted(() => {
<template>
<TwoColumn>
<div class="flex h-screen flex-1 flex-col">
<div class="flex h-14 min-h-14 items-center bg-background px-4 py-2">
<h1 class="text-xl font-bold">Dashboard</h1>
</div>
<Separator class="shrink-0" />
<ScrollArea>
<div
class="m-auto grid max-w-7xl grid-cols-1 grid-rows-[100px_100px_100px_100px] gap-4 p-4 md:grid-cols-2 md:grid-rows-[100px_100px] xl:grid-cols-4 xl:grid-rows-[100px]"
>
<Card>
<CardHeader>
<CardTitle>{{ count('tasks') }}</CardTitle>
<CardDescription>Tasks</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle>{{ count('tickets') }}</CardTitle>
<CardDescription>Tickets</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle>{{ count('users') }}</CardTitle>
<CardDescription>Users</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle> Catalyst</CardTitle>
</CardHeader>
<CardContent class="flex flex-1 flex-col gap-1">
<a
href="https://catalyst-soar.com/docs/category/catalyst-handbook"
target="_blank"
class="flex items-center rounded border p-2 text-blue-500 hover:bg-accent"
>
Open Catalyst Handbook
<ExternalLink class="ml-2 h-4 w-4" />
</a>
<a
href="/_/"
target="_blank"
class="flex items-center rounded border p-2 text-blue-500 hover:bg-accent"
>
Open Admin Interface
<ExternalLink class="ml-2 h-4 w-4" />
</a>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle> Tickets by Type</CardTitle>
</CardHeader>
<CardContent>
<TicketTypes />
</CardContent>
</Card>
<Card class="xl:col-span-2">
<CardHeader>
<CardTitle>Tickets Per Week</CardTitle>
</CardHeader>
<CardContent>
<TicketOverTime />
</CardContent>
</Card>
<Card class="xl:col-span-2">
<CardHeader>
<CardTitle>Your Open Tickets</CardTitle>
</CardHeader>
<CardContent>
<OpenTickets />
</CardContent>
</Card>
<Card class="xl:col-span-2">
<CardHeader>
<CardTitle>Your Open Tasks</CardTitle>
</CardHeader>
<CardContent>
<OpenTasks />
</CardContent>
</Card>
</div>
</ScrollArea>
</div>
<ColumnHeader title="Dashboard" />
<ColumnBody>
<ColumnBodyContainer
class="grid grid-cols-1 grid-rows-[100px_100px_100px_100px] md:grid-cols-2 md:grid-rows-[100px_100px] xl:grid-cols-4 xl:grid-rows-[100px]"
>
<Card>
<CardHeader>
<CardTitle>{{ count('tasks') }}</CardTitle>
<CardDescription>Tasks</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle>{{ count('tickets') }}</CardTitle>
<CardDescription>Tickets</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle>{{ count('users') }}</CardTitle>
<CardDescription>Users</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle>{{ count('reactions') }}</CardTitle>
<CardDescription>Reactions</CardDescription>
</CardHeader>
</Card>
<Card>
<CardHeader>
<CardTitle> Catalyst</CardTitle>
</CardHeader>
<CardContent class="flex flex-1 flex-col gap-1">
<a
href="https://catalyst.security-brewery.com/docs/category/catalyst-handbook"
target="_blank"
class="flex items-center rounded border p-2 text-blue-500 hover:bg-accent"
>
Open Catalyst Handbook
<ExternalLink class="ml-2 h-4 w-4" />
</a>
<a
href="/_/"
target="_blank"
class="flex items-center rounded border p-2 text-blue-500 hover:bg-accent"
>
Open Admin Interface
<ExternalLink class="ml-2 h-4 w-4" />
</a>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle> Tickets by Type</CardTitle>
</CardHeader>
<CardContent>
<TicketTypes />
</CardContent>
</Card>
<Card class="xl:col-span-2">
<CardHeader>
<CardTitle>Tickets Per Week</CardTitle>
</CardHeader>
<CardContent>
<TicketOverTime />
</CardContent>
</Card>
<Card class="xl:col-span-2">
<CardHeader>
<CardTitle>Your Open Tickets</CardTitle>
</CardHeader>
<CardContent>
<OpenTickets />
</CardContent>
</Card>
<Card class="xl:col-span-2">
<CardHeader>
<CardTitle>Your Open Tasks</CardTitle>
</CardHeader>
<CardContent>
<OpenTasks />
</CardContent>
</Card>
</ColumnBodyContainer>
</ColumnBody>
</TwoColumn>
</template>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts" xmlns="http://www.w3.org/1999/html">
<script setup lang="ts">
import ColumnBody from '@/components/layout/ColumnBody.vue'
import ThreeColumn from '@/components/layout/ThreeColumn.vue'
import ReactionDisplay from '@/components/reaction/ReactionDisplay.vue'
import ReactionList from '@/components/reaction/ReactionList.vue'
@@ -22,14 +23,14 @@ onMounted(() => {
</script>
<template>
<ThreeColumn>
<ThreeColumn :show-details="!!id">
<template #list>
<ReactionList />
</template>
<template #single>
<div v-if="!id" class="flex h-full w-full items-center justify-center text-lg text-gray-500">
<ColumnBody v-if="!id" class="items-center justify-center text-lg text-gray-500">
No reaction selected
</div>
</ColumnBody>
<ReactionNew v-else-if="id === 'new'" key="new" />
<ReactionDisplay v-else :key="id" :id="id" />
</template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import TanView from '@/components/TanView.vue'
import ColumnBody from '@/components/layout/ColumnBody.vue'
import ThreeColumn from '@/components/layout/ThreeColumn.vue'
import TicketDisplay from '@/components/ticket/TicketDisplay.vue'
import TicketList from '@/components/ticket/TicketList.vue'
@@ -41,20 +42,17 @@ onMounted(() => {
</script>
<template>
<ThreeColumn>
<ThreeColumn :show-details="!!id">
<template #list>
<TanView :isError="isError" :isPending="isPending" :error="error" :value="selectedType">
<TanView :isError="isError" :isPending="isPending" :error="error">
<TicketList v-if="selectedType" :key="selectedType.id" :selectedType="selectedType" />
</TanView>
</template>
<template #single>
<TanView :isError="isError" :isPending="isPending" :error="error" :value="selectedType">
<div
v-if="!id"
class="flex h-full w-full items-center justify-center text-lg text-gray-500"
>
<TanView :isError="isError" :isPending="isPending" :error="error">
<ColumnBody v-if="!id" class="items-center justify-center text-lg text-gray-500">
No ticket selected
</div>
</ColumnBody>
<TicketDisplay v-else-if="selectedType" :key="id" :selectedType="selectedType" />
</TanView>
</template>