feat(10.1-02): run registered toolbar actions and scope plugin CSS per controller
- ListToolbar renders server-filtered actions after delete as outline buttons, busy during their POST
- ListView posts {} to toolbar/{action}, toasts, reloads the list and refetches the header partial
- Lists load controller assets too, so other controllers' stylesheet links are disabled on every open
- Vite dev server proxies {prefix}/assets to summer serve; dist rebuilt
This commit is contained in:
@@ -40,6 +40,8 @@ export type AdminActionRequest = Schemas['cabana.AdminActionRequest']
|
|||||||
export type AdminActionResult = Schemas['cabana.AdminActionResult']
|
export type AdminActionResult = Schemas['cabana.AdminActionResult']
|
||||||
/** A controller's plugin script and stylesheet URLs under {base}/assets/. */
|
/** A controller's plugin script and stylesheet URLs under {base}/assets/. */
|
||||||
export type ControllerAssets = Schemas['cabana.ControllerAssets']
|
export type ControllerAssets = Schemas['cabana.ControllerAssets']
|
||||||
|
/** A registered toolbar action the admin may run, with its localized label. */
|
||||||
|
export type ToolbarAction = Schemas['cabana.ToolbarAction']
|
||||||
/** One node of a rendered partial: an allowlisted element or a text node. */
|
/** One node of a rendered partial: an allowlisted element or a text node. */
|
||||||
export type PartialNode = Schemas['cabana.PartialNode']
|
export type PartialNode = Schemas['cabana.PartialNode']
|
||||||
/** A rendered header or form partial. */
|
/** A rendered header or form partial. */
|
||||||
|
|||||||
@@ -102,8 +102,8 @@ export async function loadControllerAssets(
|
|||||||
controllerId: string,
|
controllerId: string,
|
||||||
assets: ControllerAssets | null | undefined,
|
assets: ControllerAssets | null | undefined,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
activateStyles(controllerId)
|
|
||||||
loadStyles(controllerId, assets?.styles ?? [])
|
loadStyles(controllerId, assets?.styles ?? [])
|
||||||
|
activateStyles(controllerId)
|
||||||
const urls = assets?.scripts ?? []
|
const urls = assets?.scripts ?? []
|
||||||
const results = await Promise.allSettled(urls.map((url) => loadScript(url)))
|
const results = await Promise.allSettled(urls.map((url) => loadScript(url)))
|
||||||
return urls.filter((_, index) => results[index]?.status === 'rejected')
|
return urls.filter((_, index) => results[index]?.status === 'rejected')
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
import { Check, Search, Trash2 } from '@lucide/vue'
|
import { Check, Search, Trash2 } from '@lucide/vue'
|
||||||
|
import type { ToolbarAction } from '../../api/types'
|
||||||
import { t } from '../../app/i18n'
|
import { t } from '../../app/i18n'
|
||||||
import Button from '../ui/Button.vue'
|
import Button from '../ui/Button.vue'
|
||||||
|
|
||||||
// List toolbar (design screen 3, D-14): the search input and the toolbar
|
// List toolbar (design screen 3, D-14): the search input and the toolbar
|
||||||
// buttons in declared order. Delete is a disabled outline button without a
|
// buttons in declared order. Delete is a disabled outline button without a
|
||||||
// selection and a danger outline button next to the "selected" pill with one.
|
// selection and a danger outline button next to the "selected" pill with one.
|
||||||
defineProps<{
|
// A registered plugin action (D-12) is an outline button labelled from
|
||||||
|
// `actions`, the list the server already filtered to what the admin may run;
|
||||||
|
// it ignores the selection and is disabled and busy while its POST runs.
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
showSearch: boolean
|
showSearch: boolean
|
||||||
search: string
|
search: string
|
||||||
searchPrompt: string
|
searchPrompt: string
|
||||||
@@ -14,8 +20,14 @@ defineProps<{
|
|||||||
selectedCount: number
|
selectedCount: number
|
||||||
selectedLabel: string
|
selectedLabel: string
|
||||||
deleteLabel: string
|
deleteLabel: string
|
||||||
}>()
|
actions?: ToolbarAction[]
|
||||||
const emit = defineEmits<{ 'update:search': [value: string]; delete: [] }>()
|
busyAction?: string | null
|
||||||
|
}>(),
|
||||||
|
{ actions: () => [], busyAction: null },
|
||||||
|
)
|
||||||
|
const emit = defineEmits<{ 'update:search': [value: string]; delete: []; action: [name: string] }>()
|
||||||
|
|
||||||
|
const labels = computed(() => new Map(props.actions.map((action) => [action.name, action.label])))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -52,6 +64,17 @@ const emit = defineEmits<{ 'update:search': [value: string]; delete: [] }>()
|
|||||||
>
|
>
|
||||||
{{ deleteLabel }}
|
{{ deleteLabel }}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-else-if="labels.has(button)"
|
||||||
|
variant="outline"
|
||||||
|
size="md"
|
||||||
|
:data-action="button"
|
||||||
|
:disabled="busyAction === button"
|
||||||
|
:aria-busy="busyAction === button ? 'true' : undefined"
|
||||||
|
@click="emit('action', button)"
|
||||||
|
>
|
||||||
|
{{ labels.get(button) }}
|
||||||
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { AdminRecord, ListMeta, ListQuery, ListSchema } from '../api/types'
|
|||||||
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
|
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
|
||||||
import { message, t } from '../app/i18n'
|
import { message, t } from '../app/i18n'
|
||||||
import { listQueryKey, parseListQuery, toListQuery, type RowId } from '../app/listQuery'
|
import { listQueryKey, parseListQuery, toListQuery, type RowId } from '../app/listQuery'
|
||||||
|
import { loadControllerAssets } from '../app/pluginAssets'
|
||||||
import { mapWinterUrl } from '../app/winterUrl'
|
import { mapWinterUrl } from '../app/winterUrl'
|
||||||
import DataTable from '../components/list/DataTable.vue'
|
import DataTable from '../components/list/DataTable.vue'
|
||||||
import FilterBar from '../components/list/FilterBar.vue'
|
import FilterBar from '../components/list/FilterBar.vue'
|
||||||
@@ -70,7 +71,13 @@ const subtitle = computed(() => (meta.value ? message(messages.value?.recordCoun
|
|||||||
|
|
||||||
const buttons = computed(() => schema.value?.toolbarButtons ?? [])
|
const buttons = computed(() => schema.value?.toolbarButtons ?? [])
|
||||||
const headingButtons = computed(() => buttons.value.filter((button) => button === 'create'))
|
const headingButtons = computed(() => buttons.value.filter((button) => button === 'create'))
|
||||||
const toolbarButtons = computed(() => buttons.value.filter((button) => button === 'delete'))
|
// Delete and the registered actions the admin may run, in declared order
|
||||||
|
// (D-12). An action missing from toolbarActions is not rendered at all.
|
||||||
|
const toolbarActions = computed(() => schema.value?.toolbarActions ?? [])
|
||||||
|
const toolbarButtons = computed(() =>
|
||||||
|
buttons.value.filter((button) => button === 'delete' || toolbarActions.value.some((action) => action.name === button)),
|
||||||
|
)
|
||||||
|
const busyAction = ref<string | null>(null)
|
||||||
|
|
||||||
const perPageOptions = computed(() => {
|
const perPageOptions = computed(() => {
|
||||||
const options = schema.value?.perPageOptions ?? []
|
const options = schema.value?.perPageOptions ?? []
|
||||||
@@ -116,7 +123,10 @@ async function load(): Promise<void> {
|
|||||||
const result = await api.GET('/{vendor}/{plugin}/{controller}/schema/list', { params: { path } })
|
const result = await api.GET('/{vendor}/{plugin}/{controller}/schema/list', { params: { path } })
|
||||||
schema.value = result.data?.data ?? null
|
schema.value = result.data?.data ?? null
|
||||||
schemaFailed.value = !schema.value
|
schemaFailed.value = !schema.value
|
||||||
|
if (schema.value) {
|
||||||
|
// Plugin files load in the background; the table never waits (D-14).
|
||||||
|
void loadControllerAssets(controllerId, schema.value.assets)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void load()
|
void load()
|
||||||
@@ -216,6 +226,33 @@ async function onDelete(): Promise<void> {
|
|||||||
deleting.value = false
|
deleting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Runs a registered toolbar action (D-12): no confirmation, body {}. */
|
||||||
|
async function onAction(name: string): Promise<void> {
|
||||||
|
if (busyAction.value !== null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
busyAction.value = name
|
||||||
|
try {
|
||||||
|
const result = await api.POST('/{vendor}/{plugin}/{controller}/toolbar/{action}', {
|
||||||
|
params: { path: { ...path, action: name } },
|
||||||
|
body: {},
|
||||||
|
})
|
||||||
|
if (result.data) {
|
||||||
|
if (result.data.data.message) {
|
||||||
|
showToast(result.data.data.message)
|
||||||
|
}
|
||||||
|
await loadList()
|
||||||
|
partialReload.value += 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showToast(result.error?.error.message || t('backend::lang.extension.action_failed'), 'danger')
|
||||||
|
} catch {
|
||||||
|
showToast(t('backend::lang.extension.action_failed'), 'danger')
|
||||||
|
} finally {
|
||||||
|
busyAction.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -255,8 +292,11 @@ async function onDelete(): Promise<void> {
|
|||||||
:selected-count="selected.length"
|
:selected-count="selected.length"
|
||||||
:selected-label="message(messages?.selected, selected.length)"
|
:selected-label="message(messages?.selected, selected.length)"
|
||||||
:delete-label="message(messages?.deleteSelected, selected.length)"
|
:delete-label="message(messages?.deleteSelected, selected.length)"
|
||||||
|
:actions="toolbarActions"
|
||||||
|
:busy-action="busyAction"
|
||||||
@update:search="onSearch"
|
@update:search="onSearch"
|
||||||
@delete="onDelete"
|
@delete="onDelete"
|
||||||
|
@action="onAction"
|
||||||
/>
|
/>
|
||||||
<FilterBar
|
<FilterBar
|
||||||
v-if="schema && schema.filters.length > 0"
|
v-if="schema && schema.filters.length > 0"
|
||||||
|
|||||||
10
admin/tests/fixtures/extension.list-schema.json
vendored
10
admin/tests/fixtures/extension.list-schema.json
vendored
@@ -11,9 +11,15 @@
|
|||||||
"recordUrl": "acme/demo/widgets/update/:id",
|
"recordUrl": "acme/demo/widgets/update/:id",
|
||||||
"toolbarButtons": [
|
"toolbarButtons": [
|
||||||
"create",
|
"create",
|
||||||
"delete"
|
"delete",
|
||||||
|
"recount"
|
||||||
|
],
|
||||||
|
"toolbarActions": [
|
||||||
|
{
|
||||||
|
"name": "recount",
|
||||||
|
"label": "Recount"
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"toolbarActions": [],
|
|
||||||
"assets": {
|
"assets": {
|
||||||
"scripts": [],
|
"scripts": [],
|
||||||
"styles": [
|
"styles": [
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Phase 10.1 extension point, SPA half (ADMIN-07): plugin assets, a widget
|
// Phase 10.1 extension point, SPA half (ADMIN-07): plugin assets, a widget
|
||||||
// custom element bridged to its action, header and form partials rendered
|
// custom element bridged to its action, header and form partials rendered
|
||||||
// through the client allowlist. Fixtures are neutral acme.demo.* data; no
|
// through the client allowlist, registered toolbar actions and stylesheets
|
||||||
|
// scoped to their controller. Fixtures are neutral acme.demo.* data; no
|
||||||
// application names appear in framework tests.
|
// application names appear in framework tests.
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||||
@@ -526,3 +527,113 @@ describe('form partial (D-09, UI-SPEC S2)', () => {
|
|||||||
expect(await put!.json()).toEqual({ name: 'Blue widget', color: '#0000ff' })
|
expect(await put!.json()).toEqual({ name: 'Blue widget', color: '#0000ff' })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('toolbar actions (D-12, UI-SPEC S4)', () => {
|
||||||
|
const RECOUNT = `${BASE}/toolbar/recount`
|
||||||
|
|
||||||
|
it('renders a registered action after delete, enabled without a selection', async () => {
|
||||||
|
stubAssets()
|
||||||
|
const { wrapper } = await mountApp('/acme/demo/widgets', listRoutes())
|
||||||
|
const cluster = wrapper.find('[data-action="delete"]').element.parentElement!
|
||||||
|
const names = Array.from(cluster.querySelectorAll('[data-action]')).map((item) => item.getAttribute('data-action'))
|
||||||
|
expect(names).toEqual(['delete', 'recount'])
|
||||||
|
const recount = wrapper.find('[data-action="recount"]')
|
||||||
|
expect(recount.text()).toBe('Recount')
|
||||||
|
expect(recount.attributes('disabled')).toBeUndefined()
|
||||||
|
expect(recount.attributes('aria-busy')).toBeUndefined()
|
||||||
|
expect(recount.classes()).toContain('border-border-strong')
|
||||||
|
expect(recount.find('svg').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-action="delete"]').attributes('disabled')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('posts {} while busy, toasts, reloads the list and refetches the header partial', async () => {
|
||||||
|
stubAssets()
|
||||||
|
const action = deferred()
|
||||||
|
const { wrapper, calls } = await mountApp('/acme/demo/widgets', listRoutes({ [`POST ${RECOUNT}`]: action.route }))
|
||||||
|
const lists = requestsTo(calls, 'GET', BASE).length
|
||||||
|
const partials = requestsTo(calls, 'GET', STATS).length
|
||||||
|
|
||||||
|
await wrapper.find('[data-action="recount"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
const recount = wrapper.find('[data-action="recount"]')
|
||||||
|
expect(recount.attributes('disabled')).toBeDefined()
|
||||||
|
expect(recount.attributes('aria-busy')).toBe('true')
|
||||||
|
await recount.trigger('click')
|
||||||
|
const [post, ...more] = requestsTo(calls, 'POST', RECOUNT)
|
||||||
|
expect(more).toHaveLength(0)
|
||||||
|
expect(post!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||||
|
expect(await post!.json()).toEqual({})
|
||||||
|
|
||||||
|
action.release({ body: { data: { message: 'Counts refreshed', fill: {} }, meta: {} } })
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[role="status"]').text()).toContain('Counts refreshed')
|
||||||
|
expect(requestsTo(calls, 'GET', BASE).length).toBe(lists + 1)
|
||||||
|
expect(requestsTo(calls, 'GET', STATS).length).toBe(partials + 1)
|
||||||
|
expect(wrapper.find('[data-action="recount"]').attributes('disabled')).toBeUndefined()
|
||||||
|
expect(wrapper.find('[data-action="recount"]').attributes('aria-busy')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never renders an action the admin may not run', async () => {
|
||||||
|
stubAssets()
|
||||||
|
const schema = clone(extensionListSchemaFixture)
|
||||||
|
schema.data.toolbarActions = []
|
||||||
|
const { wrapper } = await mountApp('/acme/demo/widgets', listRoutes({ [`GET ${BASE}/schema/list`]: { body: schema } }))
|
||||||
|
expect(wrapper.find('[data-action="delete"]').exists()).toBe(true)
|
||||||
|
expect(wrapper.find('[data-action="recount"]').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('[data-action="create"]').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a danger toast with the server message or the framework text', async () => {
|
||||||
|
stubAssets()
|
||||||
|
let reply: Reply = { status: 500, body: { error: { code: 'server_error', message: 'Recount failed.', details: {} } } }
|
||||||
|
const { wrapper, calls } = await mountApp('/acme/demo/widgets', listRoutes({ [`POST ${RECOUNT}`]: () => reply }))
|
||||||
|
const lists = requestsTo(calls, 'GET', BASE).length
|
||||||
|
|
||||||
|
await wrapper.find('[data-action="recount"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('[data-tone="danger"]').text()).toContain('Recount failed.')
|
||||||
|
expect(requestsTo(calls, 'GET', BASE).length).toBe(lists)
|
||||||
|
|
||||||
|
reply = { status: 403, body: {} }
|
||||||
|
await wrapper.find('[data-action="recount"]').trigger('click')
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.findAll('[data-tone="danger"]')[0]!.text()).toContain('Nie udało się wykonać akcji. Spróbuj ponownie.')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('controller stylesheets (D-14, UI-SPEC S6)', () => {
|
||||||
|
it('disables the first controller stylesheet when another controller opens', async () => {
|
||||||
|
const seen = stubAssets()
|
||||||
|
const gadgets = `${API}/acme/demo/gadgets`
|
||||||
|
const widgetsCss = '/admin-test/assets/acme/demo/css/scoped-widgets.css?v=1'
|
||||||
|
const gadgetsCss = '/admin-test/assets/acme/demo/css/scoped-gadgets.css?v=1'
|
||||||
|
const widgetsSchema = clone(extensionListSchemaFixture)
|
||||||
|
widgetsSchema.data.assets.styles = [widgetsCss]
|
||||||
|
const gadgetsSchema = clone(extensionListSchemaFixture)
|
||||||
|
gadgetsSchema.data.assets.styles = [gadgetsCss]
|
||||||
|
delete gadgetsSchema.data.headerPartial
|
||||||
|
const { router } = await mountApp(
|
||||||
|
'/acme/demo/widgets',
|
||||||
|
listRoutes({
|
||||||
|
[`GET ${BASE}/schema/list`]: { body: widgetsSchema },
|
||||||
|
[`GET ${gadgets}/schema/list`]: { body: gadgetsSchema },
|
||||||
|
[`GET ${gadgets}`]: { body: listRows },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const link = (url: string) => seen.links.find((item) => item.getAttribute('href') === url)
|
||||||
|
expect(link(widgetsCss)!.getAttribute(OWNER_ATTRIBUTE)).toBe('acme.demo.widgets')
|
||||||
|
expect(link(widgetsCss)!.disabled).toBe(false)
|
||||||
|
|
||||||
|
await router.push('/acme/demo/gadgets')
|
||||||
|
await flushPromises()
|
||||||
|
expect(link(gadgetsCss)!.getAttribute(OWNER_ATTRIBUTE)).toBe('acme.demo.gadgets')
|
||||||
|
expect(link(gadgetsCss)!.disabled).toBe(false)
|
||||||
|
expect(link(widgetsCss)!.disabled).toBe(true)
|
||||||
|
|
||||||
|
await router.push('/acme/demo/widgets')
|
||||||
|
await flushPromises()
|
||||||
|
expect(link(widgetsCss)!.disabled).toBe(false)
|
||||||
|
expect(link(gadgetsCss)!.disabled).toBe(true)
|
||||||
|
expect(seen.links.filter((item) => item.getAttribute('href') === widgetsCss)).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import tailwindcss from '@tailwindcss/vite'
|
|||||||
// The committed build is path-agnostic (D-02): assets use a relative base and
|
// The committed build is path-agnostic (D-02): assets use a relative base and
|
||||||
// index.html keeps the __SUMMER_ADMIN_BASE__ token, which the Go server
|
// index.html keeps the __SUMMER_ADMIN_BASE__ token, which the Go server
|
||||||
// (boardwalk) replaces with the configured backend.uri. The dev server
|
// (boardwalk) replaces with the configured backend.uri. The dev server
|
||||||
// replaces it with SUMMER_ADMIN_DEV_PREFIX and proxies the admin API to a
|
// replaces it with SUMMER_ADMIN_DEV_PREFIX and proxies the admin API and the
|
||||||
// running `summer serve` at SUMMER_ADMIN_DEV_TARGET.
|
// plugin assets to a running `summer serve` at SUMMER_ADMIN_DEV_TARGET.
|
||||||
const devPrefix = (process.env.SUMMER_ADMIN_DEV_PREFIX ?? '/backend').replace(/\/+$/, '')
|
const devPrefix = (process.env.SUMMER_ADMIN_DEV_PREFIX ?? '/backend').replace(/\/+$/, '')
|
||||||
const devTarget = process.env.SUMMER_ADMIN_DEV_TARGET ?? 'http://localhost:8080'
|
const devTarget = process.env.SUMMER_ADMIN_DEV_TARGET ?? 'http://localhost:8080'
|
||||||
|
|
||||||
@@ -29,6 +29,8 @@ export default defineConfig(({ command }) => ({
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
[`${devPrefix}/api`]: { target: devTarget, changeOrigin: false },
|
[`${devPrefix}/api`]: { target: devTarget, changeOrigin: false },
|
||||||
|
// Plugin JS/CSS that controllers declare (Phase 10.1), served by cabana.
|
||||||
|
[`${devPrefix}/assets`]: { target: devTarget, changeOrigin: false },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
4
modules/boardwalk/dist/assets/index-DMFEtOWd.js
vendored
Normal file
4
modules/boardwalk/dist/assets/index-DMFEtOWd.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
modules/boardwalk/dist/index.html
vendored
2
modules/boardwalk/dist/index.html
vendored
@@ -6,7 +6,7 @@
|
|||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||||
<title>SummerCMS</title>
|
<title>SummerCMS</title>
|
||||||
<script type="module" crossorigin src="./assets/index-D9vrCBt0.js"></script>
|
<script type="module" crossorigin src="./assets/index-DMFEtOWd.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-CfeX_snf.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-CfeX_snf.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Reference in New Issue
Block a user