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