mirror of https://github.com/requarks/wiki
parent
bfafd9d434
commit
7dfb9abe32
@ -1,140 +0,0 @@
|
||||
<template>
|
||||
<w-dialog v-model="dialogVisible" @hide="onDialogHide">
|
||||
<w-card class="relative" style="min-width: 650px">
|
||||
<w-card-section class="card-header">
|
||||
<w-icon name="img:/_assets/icons/fluent-rename.svg" size="sm" class="mr-2" />
|
||||
<span>{{ t(`fileman.assetRename`) }}</span>
|
||||
</w-card-section>
|
||||
<w-form class="py-2" @submit="rename">
|
||||
<w-item>
|
||||
<blueprint-icon icon="image" class="self-start" />
|
||||
<w-item-section>
|
||||
<w-input
|
||||
v-model="state.path"
|
||||
autofocus
|
||||
outlined
|
||||
dense
|
||||
hide-bottom-space
|
||||
:label="t(`fileman.assetFileName`)"
|
||||
:hint="t(`fileman.assetFileNameHint`)"
|
||||
lazy-rules="ondemand"
|
||||
@keyup:enter="rename" />
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-form>
|
||||
<w-card-actions class="card-actions">
|
||||
<w-space />
|
||||
<w-btn
|
||||
class="acrylic-btn"
|
||||
flat
|
||||
:label="t(`common.actions.cancel`)"
|
||||
color="grey"
|
||||
padding="xs md"
|
||||
@click="onDialogCancel" />
|
||||
<w-btn
|
||||
unelevated
|
||||
:label="t(`common.actions.rename`)"
|
||||
color="primary"
|
||||
padding="xs md"
|
||||
:loading="state.loading > 0"
|
||||
@click="rename" />
|
||||
</w-card-actions>
|
||||
<w-inner-loading :showing="state.loading > 0" size="38px" spinner-class="text-accent" />
|
||||
</w-card>
|
||||
</w-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
|
||||
import { notify } from '@/composables/notify'
|
||||
import { onMounted, reactive } from 'vue'
|
||||
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
import { apiErrorMessage } from '@/helpers/apiError'
|
||||
|
||||
// PROPS
|
||||
|
||||
const props = defineProps({
|
||||
assetId: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// EMITS
|
||||
|
||||
defineEmits([...dialogComponentEmits])
|
||||
|
||||
// DIALOG
|
||||
|
||||
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
|
||||
|
||||
// STORES
|
||||
|
||||
const siteStore = useSiteStore()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
path: '',
|
||||
loading: false
|
||||
})
|
||||
|
||||
// METHODS
|
||||
|
||||
async function rename() {
|
||||
state.loading++
|
||||
try {
|
||||
if (state.path?.length < 2 || !state.path?.includes('.')) {
|
||||
throw new Error(t('fileman.renameAssetInvalid'))
|
||||
}
|
||||
const resp = await API_CLIENT.patch(`sites/${siteStore.id}/assets/${props.assetId}`, {
|
||||
json: {
|
||||
fileName: state.path
|
||||
}
|
||||
}).json()
|
||||
// -> The API client does not throw on 400, so a refused name comes back as a parsed error
|
||||
if (resp?.ok === false) {
|
||||
throw new Error(resp.message || 'An unexpected error occured.')
|
||||
}
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: t('fileman.renameAssetSuccess')
|
||||
})
|
||||
onDialogOK()
|
||||
} catch (err) {
|
||||
// -> ky throws above 400 — a name already taken in this folder answers 409
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: apiErrorMessage(err)
|
||||
})
|
||||
}
|
||||
state.loading--
|
||||
}
|
||||
|
||||
// MOUNTED
|
||||
|
||||
onMounted(async () => {
|
||||
state.loading++
|
||||
try {
|
||||
const asset = await API_CLIENT.get(`sites/${siteStore.id}/assets/${props.assetId}`).json()
|
||||
if (asset?.id !== props.assetId) {
|
||||
throw new Error('Failed to fetch asset data.')
|
||||
}
|
||||
state.path = asset.fileName
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: apiErrorMessage(err)
|
||||
})
|
||||
onDialogCancel()
|
||||
}
|
||||
state.loading--
|
||||
})
|
||||
</script>
|
||||
@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<w-dialog v-model="dialogVisible" @hide="onDialogHide">
|
||||
<w-card class="folder-color relative" style="width: 420px; max-width: 90vw">
|
||||
<w-card-section class="card-header">
|
||||
<w-icon name="img:/_assets/icons/fluent-color-wheel.svg" size="sm" class="mr-2" />
|
||||
<div class="min-w-0">
|
||||
<div>{{ t('fileman.folderColor') }}</div>
|
||||
<div class="text-caption truncate">{{ folderTitle }}</div>
|
||||
</div>
|
||||
</w-card-section>
|
||||
<!--
|
||||
The swatch is the folder icon itself under the filter it is offering, rather than a plain
|
||||
square of colour: the filter is an approximation over RGB and what it makes of the icon is not
|
||||
quite the colour the angle names, so a square would be promising something slightly different
|
||||
from what the tree will show.
|
||||
-->
|
||||
<div class="folder-color-grid p-4">
|
||||
<button
|
||||
v-for="color of FOLDER_COLORS"
|
||||
:key="color.hue"
|
||||
class="folder-color-swatch w-unstyled"
|
||||
:class="color.hue === state.hue && `is-selected`"
|
||||
:aria-label="color.name"
|
||||
:aria-pressed="color.hue === state.hue"
|
||||
@click="state.hue = color.hue">
|
||||
<w-icon
|
||||
name="img:/_assets/icons/fluent-folder.svg"
|
||||
size="lg"
|
||||
:style="folderIconStyle(color.hue)" />
|
||||
<w-tooltip>{{ color.name }}</w-tooltip>
|
||||
</button>
|
||||
</div>
|
||||
<w-card-actions class="card-actions">
|
||||
<w-space />
|
||||
<w-btn
|
||||
class="acrylic-btn"
|
||||
flat
|
||||
:label="t(`common.actions.cancel`)"
|
||||
color="grey"
|
||||
padding="xs md"
|
||||
@click="onDialogCancel" />
|
||||
<w-btn
|
||||
unelevated
|
||||
:label="t(`common.actions.apply`)"
|
||||
color="primary"
|
||||
padding="xs md"
|
||||
:loading="state.loading > 0"
|
||||
@click="save" />
|
||||
</w-card-actions>
|
||||
<w-inner-loading :showing="state.loading > 0" size="38px" spinner-class="text-accent" />
|
||||
</w-card>
|
||||
</w-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { reactive } from 'vue'
|
||||
|
||||
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
|
||||
import { notify } from '@/composables/notify'
|
||||
|
||||
import { FOLDER_COLORS, folderIconStyle } from '@/helpers/folderColors'
|
||||
import { apiErrorMessage } from '@/helpers/apiError'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
/**
|
||||
* Pick the colour of a folder's icon.
|
||||
*
|
||||
* What is chosen is a hue rotation applied to the one folder icon the app draws everywhere, not a
|
||||
* colour of its own — see `helpers/folderColors`. The first swatch is a rotation of nothing, which is
|
||||
* both the colour every folder starts out and the way to put a coloured one back.
|
||||
*/
|
||||
|
||||
// PROPS
|
||||
|
||||
const props = defineProps({
|
||||
folderId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
folderTitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** The folder's current colour, so the dialog opens on it. */
|
||||
hue: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
// EMITS
|
||||
|
||||
defineEmits([...dialogComponentEmits])
|
||||
|
||||
// DIALOG
|
||||
|
||||
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
|
||||
|
||||
// STORES
|
||||
|
||||
const siteStore = useSiteStore()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
hue: props.hue,
|
||||
loading: 0
|
||||
})
|
||||
|
||||
// METHODS
|
||||
|
||||
async function save() {
|
||||
state.loading++
|
||||
try {
|
||||
const resp = await API_CLIENT.put(
|
||||
`sites/${siteStore.id}/tree/folders/${props.folderId}/color`,
|
||||
{
|
||||
json: { hue: state.hue }
|
||||
}
|
||||
).json()
|
||||
// -> The API client does not throw on 400, so a refused value comes back as a parsed error
|
||||
if (resp?.ok === false) {
|
||||
throw new Error(resp.message || 'An unexpected error occured.')
|
||||
}
|
||||
onDialogOK(state.hue)
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: 'Failed to set the folder color.',
|
||||
caption: apiErrorMessage(err, 'An unexpected error occured.')
|
||||
})
|
||||
}
|
||||
state.loading--
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.folder-color {
|
||||
&-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&-swatch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 0;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s var(--ease-standard);
|
||||
|
||||
&:hover {
|
||||
@at-root .body--light & {
|
||||
background-color: $blue-grey-1;
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
background-color: $dark-4;
|
||||
}
|
||||
}
|
||||
|
||||
// -> The border rather than a background: the swatch IS a colour, and tinting the box behind it
|
||||
// would be one more colour arguing with it
|
||||
&.is-selected,
|
||||
&:focus-visible {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,46 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
/**
|
||||
* Apply a change to the app as a single cross-fade, through the View Transition API.
|
||||
*
|
||||
* A page swap is not one paint. The title, the description, the breadcrumbs and the article are
|
||||
* separate pieces of the same screen, and each of them lands in whichever frame it is ready in -- so
|
||||
* for an instant the reader is looking at half of the page they left and half of the one arriving.
|
||||
* That is what this is for: the browser holds the old screen still, the callback makes the change,
|
||||
* and the two states are cross-faded rather than cut between.
|
||||
*
|
||||
* The update is awaited before the animation begins, so `update` may be async -- but keep it SHORT.
|
||||
* Rendering is suppressed for as long as it runs, which is exactly the point (nothing may repaint
|
||||
* half-swapped) and exactly the danger: a fetch in here freezes the interface for the length of the
|
||||
* round trip. Fetch first, then call this with the part that touches the DOM.
|
||||
*
|
||||
* Resolves once the DOM has been updated, NOT once the animation has finished -- what comes after a
|
||||
* page swap is more work on the content that just landed (loading its blocks, scrolling to the
|
||||
* heading in the URL), and none of that should wait on a fade.
|
||||
*
|
||||
* @param {() => void|Promise<void>} update Makes the change. Called exactly once, with or without a
|
||||
* transition -- so this is safe to use as the only path.
|
||||
*/
|
||||
export async function withViewTransition(update) {
|
||||
// -> Vue renders on its own schedule, so the change is not in the DOM until the queue has flushed;
|
||||
// without this the browser captures the new state before it exists and there is nothing to fade
|
||||
const applyAndRender = async () => {
|
||||
await update()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
// -> Not in every browser yet (Safari and Firefox trail Chromium here), and nothing about this is
|
||||
// load-bearing: without the API the change simply happens the way it did before
|
||||
if (!document.startViewTransition) {
|
||||
return applyAndRender()
|
||||
}
|
||||
|
||||
const transition = document.startViewTransition(applyAndRender)
|
||||
/*
|
||||
`updateCallbackDone` rather than `finished`: it rejects with whatever `update` threw, which is
|
||||
what the caller wants to see, while `finished` swallows it and resolves anyway. `ready` is the
|
||||
third one and is deliberately untouched -- it REJECTS when a transition is skipped (a second
|
||||
navigation before this one settled), which is an ordinary outcome here rather than an error.
|
||||
*/
|
||||
await transition.updateCallbackDone
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
/**
|
||||
* The colours a folder can be given in the file manager.
|
||||
*
|
||||
* Stored and applied as a HUE ROTATION rather than as a colour: the folder icon is a single yellow
|
||||
* image that every tree and listing draws, and turning it around the colour wheel is what recolours
|
||||
* it — so the set below is a set of angles, and zero is the icon left alone. That also means a wiki
|
||||
* that swaps the icon for one of its own keeps every folder's choice meaningful, where a stored
|
||||
* `#e6a817` would not.
|
||||
*
|
||||
* Ten of them, one every 36 degrees: an even sweep of the wheel that is also two full rows of five.
|
||||
*
|
||||
* The names are what each angle actually renders as, which is not quite what the arithmetic suggests:
|
||||
* the CSS filter is a matrix approximation over RGB rather than a rotation in HSL.
|
||||
*/
|
||||
export const FOLDER_COLORS = [
|
||||
{ hue: 0, name: 'Yellow' },
|
||||
{ hue: 36, name: 'Lime' },
|
||||
{ hue: 72, name: 'Green' },
|
||||
{ hue: 108, name: 'Turquoise' },
|
||||
{ hue: 144, name: 'Cyan' },
|
||||
{ hue: 180, name: 'Blue' },
|
||||
{ hue: 216, name: 'Violet' },
|
||||
{ hue: 252, name: 'Magenta' },
|
||||
{ hue: 288, name: 'Pink' },
|
||||
{ hue: 324, name: 'Orange' }
|
||||
]
|
||||
|
||||
/**
|
||||
* The CSS filter that paints a folder icon its colour.
|
||||
*
|
||||
* @param {number} [hue] Degrees around the colour wheel. Absent or zero for the colour every folder
|
||||
* starts out, which is no filter at all rather than a rotation of nothing —
|
||||
* `filter` creates a containing block and a compositing layer, and a listing
|
||||
* of a hundred untouched folders should pay for neither.
|
||||
* @returns {string|undefined} A `filter` value, or undefined to leave the icon alone.
|
||||
*/
|
||||
export function folderHueFilter(hue) {
|
||||
return hue ? `hue-rotate(${hue}deg)` : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The same thing as a style binding, which is how every tree and listing applies it.
|
||||
*
|
||||
* @param {number} [hue] Degrees around the colour wheel.
|
||||
* @returns {object|undefined} A style object, or undefined to bind nothing at all.
|
||||
*/
|
||||
export function folderIconStyle(hue) {
|
||||
const filter = folderHueFilter(hue)
|
||||
return filter ? { filter } : undefined
|
||||
}
|
||||
Loading…
Reference in new issue