mirror of https://github.com/requarks/wiki
parent
fbf0c1c689
commit
c10e988c3a
@ -0,0 +1,113 @@
|
|||||||
|
<template>
|
||||||
|
<!--
|
||||||
|
A tab hanging off the top edge of the viewport, mostly above it: all that shows at rest is a lip,
|
||||||
|
enough to find and click without standing in front of the app. Pointing at it (or opening the
|
||||||
|
menu) slides the whole thing into view.
|
||||||
|
-->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dev-tab w-unstyled font-robotomono"
|
||||||
|
:class="{ 'is-open': state.menuShown }"
|
||||||
|
aria-label="Developer tools"
|
||||||
|
:aria-expanded="state.menuShown"
|
||||||
|
@click="state.menuShown = !state.menuShown">
|
||||||
|
dev
|
||||||
|
<!--
|
||||||
|
Controlled, rather than letting WMenu bind the trigger itself: the tab stays slid down for as
|
||||||
|
long as the menu is open, which means this component has to know.
|
||||||
|
-->
|
||||||
|
<w-menu v-model="state.menuShown" anchor="bottom middle" self="top middle" :offset="[0, 4]">
|
||||||
|
<w-list dense style="min-width: 260px">
|
||||||
|
<w-item tag="label">
|
||||||
|
<w-item-section>
|
||||||
|
<w-item-label>Dark mode</w-item-label>
|
||||||
|
<w-item-label caption>
|
||||||
|
This session only. A reload restores the saved appearance.
|
||||||
|
</w-item-label>
|
||||||
|
</w-item-section>
|
||||||
|
<w-item-section side>
|
||||||
|
<w-toggle v-model="isDark" />
|
||||||
|
</w-item-section>
|
||||||
|
</w-item>
|
||||||
|
</w-list>
|
||||||
|
</w-menu>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, reactive } from 'vue'
|
||||||
|
|
||||||
|
import { useDark } from '@/composables/dark'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Developer quick menu — mounted only by a dev server, never built into a release. See the guard in
|
||||||
|
* `App.vue`, which is what keeps this out of the production bundle entirely.
|
||||||
|
*
|
||||||
|
* Switches that belong here are the ones worth flipping while looking at a screen, without an
|
||||||
|
* account, a setting, or a reload: throwaway state that the app should forget on its own. Add rows to
|
||||||
|
* the list; the tab is deliberately narrow and dumb.
|
||||||
|
*
|
||||||
|
* Strings are hardcoded English rather than going through `t()`. Nothing in here should reach the
|
||||||
|
* translators, and a dev-only key in `en.json` would be shipped to them on the next sync.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// DARK MODE
|
||||||
|
|
||||||
|
const dark = useDark()
|
||||||
|
|
||||||
|
// DATA
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
menuShown: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// COMPUTED
|
||||||
|
|
||||||
|
/*
|
||||||
|
Writes straight to the body class through the composable, deliberately going around
|
||||||
|
`userStore.appearance`: that one is persisted per user, and the point of this switch is to try the
|
||||||
|
other theme on for a minute. Nothing saves it, so the next boot applies the stored appearance as
|
||||||
|
usual -- as does anything that re-runs `applyTheme()` in App.vue, e.g. changing the real setting.
|
||||||
|
*/
|
||||||
|
const isDark = computed({
|
||||||
|
get: () => dark.isActive,
|
||||||
|
set: (value) => dark.set(value)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.dev-tab {
|
||||||
|
position: fixed;
|
||||||
|
top: -14px;
|
||||||
|
left: 50%;
|
||||||
|
/* -> Over everything the app itself can draw: notifications (9000), tooltips (7000), menus (6500+) */
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
height: 30px;
|
||||||
|
align-items: flex-end;
|
||||||
|
/* -> The label sits against the bottom edge, so it stays legible in the 16px lip that shows */
|
||||||
|
padding: 0 12px 3px;
|
||||||
|
border-radius: 0 0 5px 5px;
|
||||||
|
background-color: #7c3aed;
|
||||||
|
box-shadow: 0 1px 6px rgb(0 0 0 / 0.35);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
transition: top 0.15s var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dev-tab:hover,
|
||||||
|
.dev-tab.is-open {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.dev-tab {
|
||||||
|
transition-duration: 0.01ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,449 @@
|
|||||||
|
<template>
|
||||||
|
<w-menu
|
||||||
|
ref="menu"
|
||||||
|
class="translucent-menu"
|
||||||
|
:anchor="props.anchor"
|
||||||
|
:self="props.self"
|
||||||
|
:offset="props.offset"
|
||||||
|
@show="onShow">
|
||||||
|
<!-- -> A fixed width: the levels slide sideways past each other, so a panel that resized to its
|
||||||
|
contents would jump mid-slide. Long titles truncate instead. -->
|
||||||
|
<div class="browse-menu-panel">
|
||||||
|
<div class="browse-menu-header flex flex-nowrap items-center">
|
||||||
|
<!-- -> There is nowhere to go up to from the root, so the button is absent there rather than
|
||||||
|
sitting disabled. It slides in from the left as its own space opens up, and back out
|
||||||
|
the same way, so the title beside it moves with it instead of jumping. -->
|
||||||
|
<transition name="browse-menu-up">
|
||||||
|
<div v-if="!isRoot" class="browse-menu-up-slot">
|
||||||
|
<!-- -> The icon comes through the slot rather than the `icon` prop, which is the only way
|
||||||
|
to size it: WBtn draws a prop icon at WIcon's own default of 24px -->
|
||||||
|
<w-btn
|
||||||
|
class="browse-menu-up acrylic-btn"
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
:disable="state.isLoading"
|
||||||
|
:aria-label="t(`common.browse.upOneLevel`)"
|
||||||
|
@click="goUp">
|
||||||
|
<w-icon name="la:arrow-up" size="xs" />
|
||||||
|
<w-tooltip>{{ t('common.browse.upOneLevel') }}</w-tooltip>
|
||||||
|
</w-btn>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<!-- -> The root has no title of its own, and the site is already named in the sidebar
|
||||||
|
header directly above this, so there the path stands alone -->
|
||||||
|
<div v-if="level.title" class="truncate text-sm font-medium">{{ level.title }}</div>
|
||||||
|
<div class="text-caption truncate opacity-60 font-robotomono">/{{ state.path }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<w-separator />
|
||||||
|
<div class="browse-menu-track relative overflow-hidden">
|
||||||
|
<!-- -> Absolute, so that showing it neither shifts the rows down nor re-anchors the panel -->
|
||||||
|
<w-linear-progress
|
||||||
|
v-if="state.isLoading"
|
||||||
|
class="absolute inset-x-0 top-0 z-10"
|
||||||
|
indeterminate
|
||||||
|
size="2px" />
|
||||||
|
<transition :name="`browse-menu-${state.direction}`">
|
||||||
|
<div :key="state.path" class="browse-menu-level py-1">
|
||||||
|
<div
|
||||||
|
v-for="item of level.items"
|
||||||
|
:key="item.path"
|
||||||
|
class="browse-menu-row flex flex-nowrap items-stretch">
|
||||||
|
<!--
|
||||||
|
One row per name, whichever of the two kinds it is -- and both at once for a page
|
||||||
|
that also has a folder of pages under it. There the label opens the page and the
|
||||||
|
chevron beside it descends, so neither way in hides the other.
|
||||||
|
-->
|
||||||
|
<router-link
|
||||||
|
v-if="item.isPage"
|
||||||
|
class="browse-menu-target"
|
||||||
|
:to="`/${item.path}`"
|
||||||
|
@click="menu?.hide()">
|
||||||
|
<w-icon :name="item.icon || `la:file-alt`" size="xs" class="shrink-0 opacity-70" />
|
||||||
|
<span class="truncate">{{ item.title }}</span>
|
||||||
|
</router-link>
|
||||||
|
<!-- -> The File Manager's folder, so a folder looks the same wherever the wiki draws
|
||||||
|
one. Full strength, unlike the line icons around it: it is a colour image, and
|
||||||
|
dimming it only washes the yellow out. -->
|
||||||
|
<button v-else type="button" class="browse-menu-target" @click="descend(item)">
|
||||||
|
<w-icon name="img:/_assets/icons/fluent-folder.svg" size="xs" class="shrink-0" />
|
||||||
|
<span class="truncate">{{ item.title }}</span>
|
||||||
|
<w-space />
|
||||||
|
<w-icon name="la:angle-right" size="xs" class="shrink-0 opacity-40" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="item.isPage && item.isFolder"
|
||||||
|
type="button"
|
||||||
|
class="browse-menu-into"
|
||||||
|
:aria-label="t(`common.browse.openFolder`, { title: item.title })"
|
||||||
|
@click="descend(item)">
|
||||||
|
<w-tooltip>{{ t('common.browse.openFolder', { title: item.title }) }}</w-tooltip>
|
||||||
|
<w-icon name="la:angle-right" size="xs" class="opacity-70" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="level.items.length < 1" class="browse-menu-note">
|
||||||
|
{{ t('common.browse.empty') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="level.truncated" class="browse-menu-note">
|
||||||
|
{{ t('common.browse.truncated') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</w-menu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, reactive, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import { notify } from '@/composables/notify'
|
||||||
|
|
||||||
|
import { usePageStore } from '@/stores/page'
|
||||||
|
import { useSiteStore } from '@/stores/site'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sidebar's Browse menu: one folder of the site at a time, as a reader walks it.
|
||||||
|
*
|
||||||
|
* Opens on the folder holding the page being read, and slides a level sideways on the way in or out
|
||||||
|
* rather than nesting submenus -- a wiki tree is deep, and cascading panels would run off the screen
|
||||||
|
* a couple of levels down.
|
||||||
|
*
|
||||||
|
* What it lists comes from `tree/browse`, which decides what a reader may see; nothing here filters,
|
||||||
|
* so there is no version of this menu that shows more than the server was willing to hand over.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// PROPS
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
anchor: {
|
||||||
|
type: String,
|
||||||
|
default: 'bottom left'
|
||||||
|
},
|
||||||
|
self: {
|
||||||
|
type: String,
|
||||||
|
default: 'top left'
|
||||||
|
},
|
||||||
|
offset: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [0, 0]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// STORES
|
||||||
|
|
||||||
|
const pageStore = usePageStore()
|
||||||
|
const siteStore = useSiteStore()
|
||||||
|
|
||||||
|
// I18N
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// REFS
|
||||||
|
|
||||||
|
const menu = ref(null)
|
||||||
|
|
||||||
|
// DATA
|
||||||
|
|
||||||
|
const EMPTY_LEVEL = { title: '', items: [], truncated: false }
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
/** Slash-separated path of the folder being listed. Empty at the site root. */
|
||||||
|
path: '',
|
||||||
|
/** Which way the next level slides in from. */
|
||||||
|
direction: 'forward',
|
||||||
|
isLoading: false,
|
||||||
|
/** Levels already fetched, by path, so that walking back up is instant. */
|
||||||
|
levels: {}
|
||||||
|
})
|
||||||
|
|
||||||
|
// COMPUTED
|
||||||
|
|
||||||
|
const level = computed(() => state.levels[state.path] ?? EMPTY_LEVEL)
|
||||||
|
|
||||||
|
const isRoot = computed(() => !state.path)
|
||||||
|
|
||||||
|
// METHODS
|
||||||
|
|
||||||
|
/** Opens on the folder holding the current page, with whatever was cached from last time dropped. */
|
||||||
|
function onShow() {
|
||||||
|
state.levels = {}
|
||||||
|
state.direction = 'forward'
|
||||||
|
state.path = pageStore.folderPath
|
||||||
|
load(state.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Which fetch is the current one. Reopening the menu drops the cache and asks again, so a request
|
||||||
|
from the previous open can still land afterwards — and it must not be the one that decides the
|
||||||
|
progress bar is finished. The level it writes is keyed by its own path, so it is harmless otherwise.
|
||||||
|
*/
|
||||||
|
let latestRequest = 0
|
||||||
|
|
||||||
|
async function load(path) {
|
||||||
|
if (state.levels[path]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const request = ++latestRequest
|
||||||
|
state.isLoading = true
|
||||||
|
try {
|
||||||
|
const data = await API_CLIENT.get(`sites/${siteStore.id}/tree/browse`, {
|
||||||
|
searchParams: {
|
||||||
|
path,
|
||||||
|
locale: pageStore.locale
|
||||||
|
}
|
||||||
|
}).json()
|
||||||
|
state.levels[path] = {
|
||||||
|
title: data.title ?? '',
|
||||||
|
items: data.items ?? [],
|
||||||
|
truncated: Boolean(data.truncated)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
notify({
|
||||||
|
type: 'negative',
|
||||||
|
message: t('common.browse.loadFailed'),
|
||||||
|
caption: err.message
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
if (request === latestRequest) {
|
||||||
|
state.isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves to another level: the contents are fetched first, so the slide reveals the rows already in
|
||||||
|
* place rather than an empty panel that fills in afterwards.
|
||||||
|
*/
|
||||||
|
async function moveTo(path, direction) {
|
||||||
|
if (state.isLoading || path === state.path) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!(await load(path))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.direction = direction
|
||||||
|
state.path = path
|
||||||
|
// -> The panel is anchored by its top edge, and a level of a different length moves the other one
|
||||||
|
await nextTick()
|
||||||
|
menu.value?.updatePosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
function descend(item) {
|
||||||
|
moveTo(item.path, 'forward')
|
||||||
|
}
|
||||||
|
|
||||||
|
function goUp() {
|
||||||
|
moveTo(state.path.split('/').slice(0, -1).join('/'), 'back')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.browse-menu-panel {
|
||||||
|
width: 270px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
A fixed height, because the header's contents are not the same on every level: the root has no
|
||||||
|
title line and no up button, and letting the row size itself moved everything below it by the
|
||||||
|
difference each time a level changed. 52px is the two lines it holds at most -- a 20px title and a
|
||||||
|
20px path -- plus the space around them.
|
||||||
|
|
||||||
|
That leaves 12px above and below the 28px button, which is where the 12px beside it comes from: the
|
||||||
|
gap around it reads as even only if all four sides match. The left one is this padding, the right
|
||||||
|
one is the slot's own margin below.
|
||||||
|
*/
|
||||||
|
.browse-menu-header {
|
||||||
|
height: 52px;
|
||||||
|
padding: 0 8px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
A square target. `dense` puts 4px around the 18px icon, and pinning the min-width to the 28px the
|
||||||
|
height comes to is what keeps the box from ending up wider or narrower than it is tall, rather than
|
||||||
|
leaving the square to be a coincidence of two component defaults.
|
||||||
|
*/
|
||||||
|
.browse-menu-up {
|
||||||
|
min-width: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -> Dimmed at rest: it is the one control up here, and it should not compete with the folder name
|
||||||
|
beside it. Full strength once the pointer is on it. */
|
||||||
|
.browse-menu-up .w-icon {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-menu-up:hover .w-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The button's footprint, as its own element: `width` is what animates, so the space closes up with
|
||||||
|
the button rather than after it. Sized to match the button, which fills it exactly, plus the gap
|
||||||
|
that separates it from the title -- which belongs to the button and so goes when it goes.
|
||||||
|
*/
|
||||||
|
.browse-menu-up-slot {
|
||||||
|
flex: none;
|
||||||
|
width: 28px;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Clipped only while it moves. At rest the slot must not clip, or it would cut off the focus ring
|
||||||
|
WBtn draws just outside its own box.
|
||||||
|
*/
|
||||||
|
.browse-menu-up-enter-active,
|
||||||
|
.browse-menu-up-leave-active {
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
width 0.18s var(--ease-standard),
|
||||||
|
margin-right 0.18s var(--ease-standard),
|
||||||
|
opacity 0.18s var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -> The slide itself is on the button: a percentage transform on the slot would resolve against a
|
||||||
|
width that is zero at exactly that moment, and move nothing */
|
||||||
|
.browse-menu-up-enter-active .browse-menu-up,
|
||||||
|
.browse-menu-up-leave-active .browse-menu-up {
|
||||||
|
transition: transform 0.18s var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-menu-up-enter-from,
|
||||||
|
.browse-menu-up-leave-to {
|
||||||
|
width: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-menu-up-enter-from .browse-menu-up,
|
||||||
|
.browse-menu-up-leave-to .browse-menu-up {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.browse-menu-up-enter-active,
|
||||||
|
.browse-menu-up-leave-active,
|
||||||
|
.browse-menu-up-enter-active .browse-menu-up,
|
||||||
|
.browse-menu-up-leave-active .browse-menu-up {
|
||||||
|
transition-duration: 0.01ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-menu-target {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The page being read, marked without a line of script: a `router-link` to the current route carries
|
||||||
|
`router-link-exact-active` itself, and the menu opens on that page's own folder — so it is normally
|
||||||
|
one of the rows on screen.
|
||||||
|
*/
|
||||||
|
.browse-menu-target.router-link-exact-active {
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
@at-root .body--dark & {
|
||||||
|
color: var(--color-primary-light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-menu-into {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
/* -> The seam that says the row has two hit targets rather than one */
|
||||||
|
border-left: 1px solid rgb(0 0 0 / 0.08);
|
||||||
|
|
||||||
|
@at-root .body--dark & {
|
||||||
|
border-left-color: rgb(255 255 255 / 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Same tints WItem uses, so a row here feels like a row anywhere else */
|
||||||
|
.browse-menu-target,
|
||||||
|
.browse-menu-into {
|
||||||
|
&:hover {
|
||||||
|
background-color: rgb(0 0 0 / 0.08);
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: rgb(0 0 0 / 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
@at-root .body--dark & {
|
||||||
|
&:hover {
|
||||||
|
background-color: rgb(255 255 255 / 0.14);
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: rgb(255 255 255 / 0.22);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-menu-note {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The slide.
|
||||||
|
|
||||||
|
The incoming level stays in flow, so the panel takes its height immediately; the outgoing one is
|
||||||
|
taken out of flow for the duration, which is what lets the two overlap while they cross.
|
||||||
|
*/
|
||||||
|
.browse-menu-level {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-menu-forward-enter-active,
|
||||||
|
.browse-menu-forward-leave-active,
|
||||||
|
.browse-menu-back-enter-active,
|
||||||
|
.browse-menu-back-leave-active {
|
||||||
|
transition:
|
||||||
|
transform 0.18s var(--ease-standard),
|
||||||
|
opacity 0.18s var(--ease-standard);
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-menu-forward-leave-active,
|
||||||
|
.browse-menu-back-leave-active {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-menu-forward-enter-from,
|
||||||
|
.browse-menu-back-leave-to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browse-menu-forward-leave-to,
|
||||||
|
.browse-menu-back-enter-from {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.browse-menu-forward-enter-active,
|
||||||
|
.browse-menu-forward-leave-active,
|
||||||
|
.browse-menu-back-enter-active,
|
||||||
|
.browse-menu-back-leave-active {
|
||||||
|
transition-duration: 0.01ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
<w-dialog v-model="dialogVisible" @hide="onDialogHide">
|
||||||
|
<w-card style="width: 450px; max-width: 90vw">
|
||||||
|
<w-card-section class="card-header">
|
||||||
|
<w-icon name="la:lock" size="sm" class="mr-2" />
|
||||||
|
<span>{{ t('common.page.unlockTitle') }}</span>
|
||||||
|
</w-card-section>
|
||||||
|
<w-form ref="unlockForm" class="py-2" @submit="unlock">
|
||||||
|
<w-item>
|
||||||
|
<blueprint-icon icon="key" />
|
||||||
|
<w-item-section>
|
||||||
|
<w-input
|
||||||
|
ref="iptPassword"
|
||||||
|
v-model="state.password"
|
||||||
|
outlined
|
||||||
|
dense
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
revealable
|
||||||
|
hide-bottom-space
|
||||||
|
:label="t(`auth.fields.password`)"
|
||||||
|
:rules="passwordValidation"
|
||||||
|
lazy-rules="ondemand" />
|
||||||
|
</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
|
||||||
|
icon="la:lock-open"
|
||||||
|
:label="t(`common.page.unlock`)"
|
||||||
|
color="primary"
|
||||||
|
padding="xs md"
|
||||||
|
:loading="state.isLoading"
|
||||||
|
@click="unlock" />
|
||||||
|
</w-card-actions>
|
||||||
|
</w-card>
|
||||||
|
</w-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
|
||||||
|
import { notify } from '@/composables/notify'
|
||||||
|
|
||||||
|
import { usePageStore } from '@/stores/page'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompts for a password-protected page's password and asks the server to open it.
|
||||||
|
*
|
||||||
|
* Confirming resolves the dialog only once the server has accepted the password and the page store
|
||||||
|
* holds the content — so whoever opened this can take `ok` to mean the page is readable, and a wrong
|
||||||
|
* guess leaves the prompt up to try again.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// EMITS
|
||||||
|
|
||||||
|
defineEmits([...dialogComponentEmits])
|
||||||
|
|
||||||
|
// REFS
|
||||||
|
|
||||||
|
const iptPassword = ref(null)
|
||||||
|
const unlockForm = ref(null)
|
||||||
|
|
||||||
|
// DIALOG
|
||||||
|
|
||||||
|
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent({
|
||||||
|
autofocus: () => iptPassword.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// STORES
|
||||||
|
|
||||||
|
const pageStore = usePageStore()
|
||||||
|
|
||||||
|
// I18N
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// DATA
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
password: '',
|
||||||
|
isLoading: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// VALIDATION RULES
|
||||||
|
|
||||||
|
const passwordValidation = [(val) => val.length > 0 || t('auth.errors.missingPassword')]
|
||||||
|
|
||||||
|
// METHODS
|
||||||
|
|
||||||
|
async function unlock() {
|
||||||
|
state.isLoading = true
|
||||||
|
try {
|
||||||
|
if (!(await unlockForm.value.validate(true))) {
|
||||||
|
throw new Error(t('auth.errors.missingPassword'))
|
||||||
|
}
|
||||||
|
await pageStore.pageUnlock(state.password)
|
||||||
|
onDialogOK()
|
||||||
|
} catch (err) {
|
||||||
|
notify({
|
||||||
|
type: 'negative',
|
||||||
|
// -> A rejected password is the expected outcome here, not a failure to report as one
|
||||||
|
message: err.response?.status === 401 ? t('common.page.lockedWrongPassword') : err.message
|
||||||
|
})
|
||||||
|
// -> Cleared and refocused, because the next thing a reader does is type it again
|
||||||
|
state.password = ''
|
||||||
|
iptPassword.value?.focus()
|
||||||
|
}
|
||||||
|
state.isLoading = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@ -1,142 +0,0 @@
|
|||||||
<template>
|
|
||||||
<w-menu
|
|
||||||
auto-close
|
|
||||||
anchor="bottom middle"
|
|
||||||
self="top middle"
|
|
||||||
@show="menuShown"
|
|
||||||
@before-hide="menuHidden">
|
|
||||||
<w-list dense padding>
|
|
||||||
<w-item clickable ref="copyUrlButton">
|
|
||||||
<w-item-section class="items-center" avatar>
|
|
||||||
<w-icon color="grey" name="la:clipboard" size="sm" />
|
|
||||||
</w-item-section>
|
|
||||||
<w-item-section class="pr-4">Copy URL</w-item-section>
|
|
||||||
</w-item>
|
|
||||||
<w-item
|
|
||||||
clickable
|
|
||||||
tag="a"
|
|
||||||
:href="`mailto:?subject=` + encodeURIComponent(props.title) + `&body=` + encodeURIComponent(urlFormatted) + `%0D%0A%0D%0A` + encodeURIComponent(props.description)"
|
|
||||||
target="_blank">
|
|
||||||
<w-item-section class="items-center" avatar>
|
|
||||||
<w-icon color="grey" name="la:envelope" size="sm" />
|
|
||||||
</w-item-section>
|
|
||||||
<w-item-section class="pr-4">Email</w-item-section>
|
|
||||||
</w-item>
|
|
||||||
</w-list>
|
|
||||||
</w-menu>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
import { notify } from '@/composables/notify'
|
|
||||||
|
|
||||||
import ClipboardJS from 'clipboard'
|
|
||||||
|
|
||||||
// PROPS
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
url: {
|
|
||||||
type: String,
|
|
||||||
default: null
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: 'Untitled Page'
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
// I18N
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
// DATA
|
|
||||||
|
|
||||||
const state = reactive({
|
|
||||||
width: 626,
|
|
||||||
height: 436,
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
clip: null
|
|
||||||
})
|
|
||||||
let clip = null
|
|
||||||
|
|
||||||
const copyUrlButton = ref(null)
|
|
||||||
|
|
||||||
// COMPUTED
|
|
||||||
|
|
||||||
const urlFormatted = computed(() => {
|
|
||||||
if (!import.meta.env.SSR) {
|
|
||||||
return props.url ? props.url : window.location.href
|
|
||||||
} else {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
// METHODS
|
|
||||||
|
|
||||||
function openSocialPop(url) {
|
|
||||||
const popupWindow = window.open(
|
|
||||||
url,
|
|
||||||
'sharer',
|
|
||||||
`status=no,height=${state.height},width=${state.width},resizable=yes,left=${state.left},top=${state.top},screenX=${state.left},screenY=${state.top},toolbar=no,menubar=no,scrollbars=no,location=no,directories=no`
|
|
||||||
)
|
|
||||||
|
|
||||||
popupWindow.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
function menuShown(ev) {
|
|
||||||
clip = new ClipboardJS(copyUrlButton.value.$el, {
|
|
||||||
text: () => {
|
|
||||||
return urlFormatted.value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
clip.on('success', () => {
|
|
||||||
notify({
|
|
||||||
message: 'URL copied successfully',
|
|
||||||
icon: 'la:clipboard'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
clip.on('error', () => {
|
|
||||||
notify({
|
|
||||||
type: 'negative',
|
|
||||||
message: 'Failed to copy to clipboard'
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function menuHidden(ev) {
|
|
||||||
clip.destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MOUNTED
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
/**
|
|
||||||
* Center the popup on dual screens
|
|
||||||
* http://stackoverflow.com/questions/4068373/center-a-popup-window-on-screen/32261263
|
|
||||||
*/
|
|
||||||
const dualScreenLeft = window.screenLeft !== undefined ? window.screenLeft : screen.left
|
|
||||||
const dualScreenTop = window.screenTop !== undefined ? window.screenTop : screen.top
|
|
||||||
|
|
||||||
const width = window.innerWidth
|
|
||||||
? window.innerWidth
|
|
||||||
: document.documentElement.clientWidth
|
|
||||||
? document.documentElement.clientWidth
|
|
||||||
: screen.width
|
|
||||||
const height = window.innerHeight
|
|
||||||
? window.innerHeight
|
|
||||||
: document.documentElement.clientHeight
|
|
||||||
? document.documentElement.clientHeight
|
|
||||||
: screen.height
|
|
||||||
|
|
||||||
state.left = width / 2 - state.width / 2 + dualScreenLeft
|
|
||||||
state.top = height / 2 - state.height / 2 + dualScreenTop
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
Loading…
Reference in new issue