mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
385 lines
12 KiB
Vue
385 lines
12 KiB
Vue
<template>
|
|
<div class="p-4">
|
|
<section class="flex justify-between items-center">
|
|
<div class="flex items-center gap-2">
|
|
<FontAwesomeIcon
|
|
:icon="['fas', 'window-maximize']"
|
|
class="size-5"
|
|
/>
|
|
<div>
|
|
<h2 class="text-sm font-semibold">
|
|
{{ curContext?.name || $t('browser.title') }}
|
|
</h2>
|
|
<p class="text-xs text-muted-foreground">
|
|
{{ curContext?.id }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
<Separator class="mt-4 mb-6" />
|
|
|
|
<form @submit="handleSave">
|
|
<div class="space-y-4">
|
|
<FormField
|
|
v-slot="{ componentField }"
|
|
name="name"
|
|
>
|
|
<FormItem>
|
|
<Label>{{ $t('browser.name') }}</Label>
|
|
<FormControl>
|
|
<Input
|
|
type="text"
|
|
:placeholder="$t('browser.namePlaceholder')"
|
|
v-bind="componentField"
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
</FormField>
|
|
|
|
<Separator class="my-4" />
|
|
<h3 class="text-xs font-medium text-foreground">
|
|
{{ $t('browser.config') }}
|
|
</h3>
|
|
|
|
<FormField
|
|
v-slot="{ value, handleChange }"
|
|
name="core"
|
|
>
|
|
<FormItem>
|
|
<Label>{{ $t('browser.core') }}</Label>
|
|
<FormControl>
|
|
<div class="flex gap-3">
|
|
<button
|
|
v-for="c in availableCores"
|
|
:key="c"
|
|
type="button"
|
|
class="flex items-center gap-2 px-3 py-1.5 rounded-md border text-xs transition-colors"
|
|
:class="value === c
|
|
? 'border-primary bg-primary/10 text-primary font-medium'
|
|
: 'border-border bg-card text-muted-foreground hover:bg-accent'"
|
|
@click="handleChange(c)"
|
|
>
|
|
{{ $t(`browser.${c}`) }}
|
|
</button>
|
|
</div>
|
|
</FormControl>
|
|
</FormItem>
|
|
</FormField>
|
|
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<FormField
|
|
v-slot="{ componentField }"
|
|
name="viewportWidth"
|
|
>
|
|
<FormItem>
|
|
<Label>{{ $t('browser.viewportWidth') }}</Label>
|
|
<FormControl>
|
|
<Input
|
|
type="number"
|
|
v-bind="componentField"
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
</FormField>
|
|
|
|
<FormField
|
|
v-slot="{ componentField }"
|
|
name="viewportHeight"
|
|
>
|
|
<FormItem>
|
|
<Label>{{ $t('browser.viewportHeight') }}</Label>
|
|
<FormControl>
|
|
<Input
|
|
type="number"
|
|
v-bind="componentField"
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
</FormField>
|
|
</div>
|
|
|
|
<FormField
|
|
v-slot="{ componentField }"
|
|
name="userAgent"
|
|
>
|
|
<FormItem>
|
|
<Label>{{ $t('browser.userAgent') }}</Label>
|
|
<FormControl>
|
|
<Input
|
|
type="text"
|
|
:placeholder="$t('browser.userAgentPlaceholder')"
|
|
v-bind="componentField"
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
</FormField>
|
|
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<FormField
|
|
v-slot="{ componentField }"
|
|
name="deviceScaleFactor"
|
|
>
|
|
<FormItem>
|
|
<Label>{{ $t('browser.deviceScaleFactor') }}</Label>
|
|
<FormControl>
|
|
<Input
|
|
type="number"
|
|
step="0.1"
|
|
v-bind="componentField"
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
</FormField>
|
|
|
|
<FormField
|
|
v-slot="{ componentField }"
|
|
name="locale"
|
|
>
|
|
<FormItem>
|
|
<Label>{{ $t('browser.locale') }}</Label>
|
|
<FormControl>
|
|
<Input
|
|
type="text"
|
|
:placeholder="$t('browser.localePlaceholder')"
|
|
v-bind="componentField"
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
</FormField>
|
|
</div>
|
|
|
|
<FormField
|
|
v-slot="{ value, handleChange }"
|
|
name="timezoneId"
|
|
>
|
|
<FormItem>
|
|
<Label>{{ $t('browser.timezoneId') }}</Label>
|
|
<FormControl>
|
|
<TimezoneSelect
|
|
:model-value="value || emptyTimezoneValue"
|
|
:placeholder="$t('browser.timezonePlaceholder')"
|
|
allow-empty
|
|
:empty-label="$t('common.optional')"
|
|
@update:model-value="(val) => handleChange(val === emptyTimezoneValue ? '' : val)"
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
</FormField>
|
|
|
|
<div class="flex items-center gap-4">
|
|
<FormField
|
|
v-slot="{ value, handleChange }"
|
|
name="isMobile"
|
|
>
|
|
<FormItem class="flex items-center gap-2">
|
|
<FormControl>
|
|
<Switch
|
|
:model-value="value"
|
|
@update:model-value="handleChange"
|
|
/>
|
|
</FormControl>
|
|
<Label class="mt-0!">{{ $t('browser.isMobile') }}</Label>
|
|
</FormItem>
|
|
</FormField>
|
|
|
|
<FormField
|
|
v-slot="{ value, handleChange }"
|
|
name="ignoreHTTPSErrors"
|
|
>
|
|
<FormItem class="flex items-center gap-2">
|
|
<FormControl>
|
|
<Switch
|
|
:model-value="value"
|
|
@update:model-value="handleChange"
|
|
/>
|
|
</FormControl>
|
|
<Label class="mt-0!">{{ $t('browser.ignoreHTTPSErrors') }}</Label>
|
|
</FormItem>
|
|
</FormField>
|
|
</div>
|
|
</div>
|
|
|
|
<section class="flex justify-end mt-6 gap-4">
|
|
<ConfirmPopover
|
|
:message="$t('browser.deleteConfirm')"
|
|
:confirm-text="$t('common.delete')"
|
|
:loading="isDeleting"
|
|
@confirm="handleDelete"
|
|
>
|
|
<template #trigger>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
:aria-label="$t('common.delete')"
|
|
>
|
|
<FontAwesomeIcon :icon="['far', 'trash-can']" />
|
|
</Button>
|
|
</template>
|
|
</ConfirmPopover>
|
|
<LoadingButton
|
|
type="submit"
|
|
:loading="isSaving"
|
|
>
|
|
{{ $t('common.save') }}
|
|
</LoadingButton>
|
|
</section>
|
|
</form>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import {
|
|
Input,
|
|
FormField,
|
|
FormControl,
|
|
FormItem,
|
|
Label,
|
|
Separator,
|
|
Button,
|
|
Switch,
|
|
} from '@memohai/ui'
|
|
import { toTypedSchema } from '@vee-validate/zod'
|
|
import z from 'zod'
|
|
import { useForm } from 'vee-validate'
|
|
import { useMutation, useQuery, useQueryCache } from '@pinia/colada'
|
|
import { putBrowserContextsById, deleteBrowserContextsById } from '@memohai/sdk'
|
|
import { getBrowserContextsCoresQuery } from '@memohai/sdk/colada'
|
|
import type { BrowsercontextsBrowserContext, BrowsercontextsUpdateRequest } from '@memohai/sdk'
|
|
import { inject, watch, computed, type Ref } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { toast } from 'vue-sonner'
|
|
import { useDialogMutation } from '@/composables/useDialogMutation'
|
|
import ConfirmPopover from '@/components/confirm-popover/index.vue'
|
|
import LoadingButton from '@/components/loading-button/index.vue'
|
|
import { resolveApiErrorMessage } from '@/utils/api-error'
|
|
import { emptyTimezoneValue } from '@/utils/timezones'
|
|
import TimezoneSelect from '@/components/timezone-select/index.vue'
|
|
|
|
const { t } = useI18n()
|
|
const { run } = useDialogMutation()
|
|
const queryCache = useQueryCache()
|
|
|
|
const curContext = inject<Ref<BrowsercontextsBrowserContext | undefined>>('curBrowserContext')
|
|
|
|
const { data: coresData } = useQuery(getBrowserContextsCoresQuery())
|
|
const availableCores = computed(() => coresData.value?.cores ?? ['chromium'])
|
|
|
|
interface ConfigShape {
|
|
core?: string
|
|
viewport?: { width?: number; height?: number }
|
|
userAgent?: string
|
|
deviceScaleFactor?: number
|
|
isMobile?: boolean
|
|
locale?: string
|
|
timezoneId?: string
|
|
ignoreHTTPSErrors?: boolean
|
|
}
|
|
|
|
function parseConfig(ctx: BrowsercontextsBrowserContext | undefined): ConfigShape {
|
|
if (!ctx?.config) return {}
|
|
if (typeof ctx.config === 'string') {
|
|
try { return JSON.parse(ctx.config) } catch { return {} }
|
|
}
|
|
if (typeof ctx.config === 'object' && !Array.isArray(ctx.config)) {
|
|
return ctx.config as unknown as ConfigShape
|
|
}
|
|
return {}
|
|
}
|
|
|
|
const schema = toTypedSchema(z.object({
|
|
name: z.string().min(1),
|
|
core: z.enum(['chromium', 'firefox']).optional(),
|
|
viewportWidth: z.coerce.number().optional(),
|
|
viewportHeight: z.coerce.number().optional(),
|
|
userAgent: z.string().optional(),
|
|
deviceScaleFactor: z.coerce.number().optional(),
|
|
isMobile: z.boolean().optional(),
|
|
locale: z.string().optional(),
|
|
timezoneId: z.string().optional(),
|
|
ignoreHTTPSErrors: z.boolean().optional(),
|
|
}))
|
|
|
|
const form = useForm({ validationSchema: schema })
|
|
|
|
watch(() => curContext?.value, (ctx) => {
|
|
if (!ctx) return
|
|
const cfg = parseConfig(ctx)
|
|
form.resetForm({
|
|
values: {
|
|
name: ctx.name || '',
|
|
core: (cfg.core as 'chromium' | 'firefox') ?? 'chromium',
|
|
viewportWidth: cfg.viewport?.width ?? 1280,
|
|
viewportHeight: cfg.viewport?.height ?? 720,
|
|
userAgent: cfg.userAgent ?? '',
|
|
deviceScaleFactor: cfg.deviceScaleFactor ?? 1,
|
|
isMobile: cfg.isMobile ?? false,
|
|
locale: cfg.locale ?? '',
|
|
timezoneId: cfg.timezoneId ?? '',
|
|
ignoreHTTPSErrors: cfg.ignoreHTTPSErrors ?? false,
|
|
},
|
|
})
|
|
}, { immediate: true })
|
|
|
|
const { mutateAsync: updateMutation, isLoading: isSaving } = useMutation({
|
|
mutation: async (data: { id: string; name: string; config: Record<string, unknown> }) => {
|
|
const { data: result } = await putBrowserContextsById({
|
|
path: { id: data.id },
|
|
body: { name: data.name, config: data.config } as BrowsercontextsUpdateRequest,
|
|
throwOnError: true,
|
|
})
|
|
return result
|
|
},
|
|
onSettled: () => queryCache.invalidateQueries({ key: ['browser-contexts'] }),
|
|
})
|
|
|
|
const { mutateAsync: deleteMutation, isLoading: isDeleting } = useMutation({
|
|
mutation: async (id: string) => {
|
|
await deleteBrowserContextsById({
|
|
path: { id },
|
|
throwOnError: true,
|
|
})
|
|
},
|
|
onSettled: () => queryCache.invalidateQueries({ key: ['browser-contexts'] }),
|
|
})
|
|
|
|
const handleSave = form.handleSubmit(async (values) => {
|
|
const id = curContext?.value?.id
|
|
if (!id) return
|
|
|
|
const config: Record<string, unknown> = {
|
|
core: values.core ?? 'chromium',
|
|
}
|
|
if (values.viewportWidth || values.viewportHeight) {
|
|
config.viewport = {
|
|
width: values.viewportWidth || 1280,
|
|
height: values.viewportHeight || 720,
|
|
}
|
|
}
|
|
if (values.userAgent) config.userAgent = values.userAgent
|
|
if (values.deviceScaleFactor) config.deviceScaleFactor = values.deviceScaleFactor
|
|
if (values.isMobile) config.isMobile = values.isMobile
|
|
if (values.locale) config.locale = values.locale
|
|
if (values.timezoneId) config.timezoneId = values.timezoneId
|
|
if (values.ignoreHTTPSErrors) config.ignoreHTTPSErrors = values.ignoreHTTPSErrors
|
|
|
|
await run(
|
|
() => updateMutation({ id, name: values.name, config }),
|
|
{
|
|
fallbackMessage: t('common.saveFailed'),
|
|
onSuccess: () => toast.success(t('browser.saveSuccess')),
|
|
},
|
|
)
|
|
})
|
|
|
|
async function handleDelete() {
|
|
const id = curContext?.value?.id
|
|
if (!id) return
|
|
try {
|
|
await deleteMutation(id)
|
|
toast.success(t('browser.deleteSuccess'))
|
|
} catch (err) {
|
|
toast.error(resolveApiErrorMessage(err, t('common.deleteFailed')))
|
|
}
|
|
}
|
|
</script>
|