diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2022-12-27 14:36:33 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2022-12-27 14:36:33 +0900 |
| commit | 9384f5399da39e53855beb8e7f8ded1aa56bf72e (patch) | |
| tree | ce5959571a981b9c4047da3c7b3fd080aa44222c /packages/frontend/src/ui/deck | |
| parent | wip: retention for dashboard (diff) | |
| download | sharkey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.gz sharkey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.tar.bz2 sharkey-9384f5399da39e53855beb8e7f8ded1aa56bf72e.zip | |
rename: client -> frontend
Diffstat (limited to 'packages/frontend/src/ui/deck')
| -rw-r--r-- | packages/frontend/src/ui/deck/antenna-column.vue | 70 | ||||
| -rw-r--r-- | packages/frontend/src/ui/deck/column-core.vue | 34 | ||||
| -rw-r--r-- | packages/frontend/src/ui/deck/column.vue | 398 | ||||
| -rw-r--r-- | packages/frontend/src/ui/deck/deck-store.ts | 296 | ||||
| -rw-r--r-- | packages/frontend/src/ui/deck/direct-column.vue | 31 | ||||
| -rw-r--r-- | packages/frontend/src/ui/deck/list-column.vue | 58 | ||||
| -rw-r--r-- | packages/frontend/src/ui/deck/main-column.vue | 68 | ||||
| -rw-r--r-- | packages/frontend/src/ui/deck/mentions-column.vue | 28 | ||||
| -rw-r--r-- | packages/frontend/src/ui/deck/notifications-column.vue | 44 | ||||
| -rw-r--r-- | packages/frontend/src/ui/deck/tl-column.vue | 119 | ||||
| -rw-r--r-- | packages/frontend/src/ui/deck/widgets-column.vue | 69 |
11 files changed, 1215 insertions, 0 deletions
diff --git a/packages/frontend/src/ui/deck/antenna-column.vue b/packages/frontend/src/ui/deck/antenna-column.vue new file mode 100644 index 0000000000..ba14530662 --- /dev/null +++ b/packages/frontend/src/ui/deck/antenna-column.vue @@ -0,0 +1,70 @@ +<template> +<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header> + <i class="ti ti-antenna"></i><span style="margin-left: 8px;">{{ column.name }}</span> + </template> + + <XTimeline v-if="column.antennaId" ref="timeline" src="antenna" :antenna="column.antennaId" @after="() => emit('loaded')"/> +</XColumn> +</template> + +<script lang="ts" setup> +import { onMounted } from 'vue'; +import XColumn from './column.vue'; +import { updateColumn, Column } from './deck-store'; +import XTimeline from '@/components/MkTimeline.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'loaded'): void; + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +let timeline = $ref<InstanceType<typeof XTimeline>>(); + +onMounted(() => { + if (props.column.antennaId == null) { + setAntenna(); + } +}); + +async function setAntenna() { + const antennas = await os.api('antennas/list'); + const { canceled, result: antenna } = await os.select({ + title: i18n.ts.selectAntenna, + items: antennas.map(x => ({ + value: x, text: x.name, + })), + default: props.column.antennaId, + }); + if (canceled) return; + updateColumn(props.column.id, { + antennaId: antenna.id, + }); +} + +const menu = [{ + icon: 'ti ti-pencil', + text: i18n.ts.selectAntenna, + action: setAntenna, +}]; + +/* +function focus() { + timeline.focus(); +} + +defineExpose({ + focus, +}); +*/ +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/frontend/src/ui/deck/column-core.vue b/packages/frontend/src/ui/deck/column-core.vue new file mode 100644 index 0000000000..30c0dc5e1c --- /dev/null +++ b/packages/frontend/src/ui/deck/column-core.vue @@ -0,0 +1,34 @@ +<template> +<!-- TODO: リファクタの余地がありそう --> +<div v-if="!column">たぶん見えちゃいけないやつ</div> +<XMainColumn v-else-if="column.type === 'main'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> +<XWidgetsColumn v-else-if="column.type === 'widgets'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> +<XNotificationsColumn v-else-if="column.type === 'notifications'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> +<XTlColumn v-else-if="column.type === 'tl'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> +<XListColumn v-else-if="column.type === 'list'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> +<XAntennaColumn v-else-if="column.type === 'antenna'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> +<XMentionsColumn v-else-if="column.type === 'mentions'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> +<XDirectColumn v-else-if="column.type === 'direct'" :column="column" :is-stacked="isStacked" @parent-focus="emit('parent-focus', $event)"/> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XMainColumn from './main-column.vue'; +import XTlColumn from './tl-column.vue'; +import XAntennaColumn from './antenna-column.vue'; +import XListColumn from './list-column.vue'; +import XNotificationsColumn from './notifications-column.vue'; +import XWidgetsColumn from './widgets-column.vue'; +import XMentionsColumn from './mentions-column.vue'; +import XDirectColumn from './direct-column.vue'; +import { Column } from './deck-store'; + +defineProps<{ + column?: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); +</script> diff --git a/packages/frontend/src/ui/deck/column.vue b/packages/frontend/src/ui/deck/column.vue new file mode 100644 index 0000000000..2a99b621e6 --- /dev/null +++ b/packages/frontend/src/ui/deck/column.vue @@ -0,0 +1,398 @@ +<template> +<!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> +<section + v-hotkey="keymap" class="dnpfarvg _narrow_" + :class="{ paged: isMainColumn, naked, active, isStacked, draghover, dragging, dropready }" + @dragover.prevent.stop="onDragover" + @dragleave="onDragleave" + @drop.prevent.stop="onDrop" +> + <header + :class="{ indicated }" + draggable="true" + @click="goTop" + @dragstart="onDragstart" + @dragend="onDragend" + @contextmenu.prevent.stop="onContextmenu" + > + <button v-if="isStacked && !isMainColumn" class="toggleActive _button" @click="toggleActive"> + <template v-if="active"><i class="ti ti-chevron-up"></i></template> + <template v-else><i class="ti ti-chevron-down"></i></template> + </button> + <div class="action"> + <slot name="action"></slot> + </div> + <span class="header"><slot name="header"></slot></span> + <button v-tooltip="i18n.ts.settings" class="menu _button" @click.stop="showSettingsMenu"><i class="ti ti-dots"></i></button> + </header> + <div v-show="active" ref="body"> + <slot></slot> + </div> +</section> +</template> + +<script lang="ts" setup> +import { onBeforeUnmount, onMounted, provide, Ref, watch } from 'vue'; +import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn, Column, deckStore } from './deck-store'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { MenuItem } from '@/types/menu'; + +provide('shouldHeaderThin', true); +provide('shouldOmitHeaderTitle', true); +provide('shouldSpacerMin', true); + +const props = withDefaults(defineProps<{ + column: Column; + isStacked?: boolean; + naked?: boolean; + indicated?: boolean; + menu?: MenuItem[]; +}>(), { + isStacked: false, + naked: false, + indicated: false, +}); + +const emit = defineEmits<{ + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; + (ev: 'change-active-state', v: boolean): void; +}>(); + +let body = $ref<HTMLDivElement>(); + +let dragging = $ref(false); +watch($$(dragging), v => os.deckGlobalEvents.emit(v ? 'column.dragStart' : 'column.dragEnd')); + +let draghover = $ref(false); +let dropready = $ref(false); + +const isMainColumn = $computed(() => props.column.type === 'main'); +const active = $computed(() => props.column.active !== false); +watch($$(active), v => emit('change-active-state', v)); + +const keymap = $computed(() => ({ + 'shift+up': () => emit('parent-focus', 'up'), + 'shift+down': () => emit('parent-focus', 'down'), + 'shift+left': () => emit('parent-focus', 'left'), + 'shift+right': () => emit('parent-focus', 'right'), +})); + +onMounted(() => { + os.deckGlobalEvents.on('column.dragStart', onOtherDragStart); + os.deckGlobalEvents.on('column.dragEnd', onOtherDragEnd); +}); + +onBeforeUnmount(() => { + os.deckGlobalEvents.off('column.dragStart', onOtherDragStart); + os.deckGlobalEvents.off('column.dragEnd', onOtherDragEnd); +}); + +function onOtherDragStart() { + dropready = true; +} + +function onOtherDragEnd() { + dropready = false; +} + +function toggleActive() { + if (!props.isStacked) return; + updateColumn(props.column.id, { + active: !props.column.active, + }); +} + +function getMenu() { + let items = [{ + icon: 'ti ti-settings', + text: i18n.ts._deck.configureColumn, + action: async () => { + const { canceled, result } = await os.form(props.column.name, { + name: { + type: 'string', + label: i18n.ts.name, + default: props.column.name, + }, + width: { + type: 'number', + label: i18n.ts.width, + default: props.column.width, + }, + flexible: { + type: 'boolean', + label: i18n.ts.flexible, + default: props.column.flexible, + }, + }); + if (canceled) return; + updateColumn(props.column.id, result); + }, + }, { + type: 'parent', + text: i18n.ts.move + '...', + icon: 'ti ti-arrows-move', + children: [{ + icon: 'ti ti-arrow-left', + text: i18n.ts._deck.swapLeft, + action: () => { + swapLeftColumn(props.column.id); + }, + }, { + icon: 'ti ti-arrow-right', + text: i18n.ts._deck.swapRight, + action: () => { + swapRightColumn(props.column.id); + }, + }, props.isStacked ? { + icon: 'ti ti-arrow-up', + text: i18n.ts._deck.swapUp, + action: () => { + swapUpColumn(props.column.id); + }, + } : undefined, props.isStacked ? { + icon: 'ti ti-arrow-down', + text: i18n.ts._deck.swapDown, + action: () => { + swapDownColumn(props.column.id); + }, + } : undefined], + }, { + icon: 'ti ti-stack-2', + text: i18n.ts._deck.stackLeft, + action: () => { + stackLeftColumn(props.column.id); + }, + }, props.isStacked ? { + icon: 'ti ti-window-maximize', + text: i18n.ts._deck.popRight, + action: () => { + popRightColumn(props.column.id); + }, + } : undefined, null, { + icon: 'ti ti-trash', + text: i18n.ts.remove, + danger: true, + action: () => { + removeColumn(props.column.id); + }, + }]; + + if (props.menu) { + items.unshift(null); + items = props.menu.concat(items); + } + + return items; +} + +function showSettingsMenu(ev: MouseEvent) { + os.popupMenu(getMenu(), ev.currentTarget ?? ev.target); +} + +function onContextmenu(ev: MouseEvent) { + os.contextMenu(getMenu(), ev); +} + +function goTop() { + body.scrollTo({ + top: 0, + behavior: 'smooth', + }); +} + +function onDragstart(ev) { + ev.dataTransfer.effectAllowed = 'move'; + ev.dataTransfer.setData(_DATA_TRANSFER_DECK_COLUMN_, props.column.id); + + // Chromeのバグで、Dragstartハンドラ内ですぐにDOMを変更する(=リアクティブなプロパティを変更する)とDragが終了してしまう + // SEE: https://stackoverflow.com/questions/19639969/html5-dragend-event-firing-immediately + window.setTimeout(() => { + dragging = true; + }, 10); +} + +function onDragend(ev) { + dragging = false; +} + +function onDragover(ev) { + // 自分自身がドラッグされている場合 + if (dragging) { + // 自分自身にはドロップさせない + ev.dataTransfer.dropEffect = 'none'; + } else { + const isDeckColumn = ev.dataTransfer.types[0] === _DATA_TRANSFER_DECK_COLUMN_; + + ev.dataTransfer.dropEffect = isDeckColumn ? 'move' : 'none'; + + if (isDeckColumn) draghover = true; + } +} + +function onDragleave() { + draghover = false; +} + +function onDrop(ev) { + draghover = false; + os.deckGlobalEvents.emit('column.dragEnd'); + + const id = ev.dataTransfer.getData(_DATA_TRANSFER_DECK_COLUMN_); + if (id != null && id !== '') { + swapColumn(props.column.id, id); + } +} +</script> + +<style lang="scss" scoped> +.dnpfarvg { + --root-margin: 10px; + --deckColumnHeaderHeight: 40px; + + height: 100%; + overflow: hidden; + contain: strict; + + &.draghover { + &:after { + content: ""; + display: block; + position: absolute; + z-index: 1000; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--focus); + } + } + + &.dragging { + &:after { + content: ""; + display: block; + position: absolute; + z-index: 1000; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--focus); + opacity: 0.5; + } + } + + &.dropready { + * { + pointer-events: none; + } + } + + &:not(.active) { + flex-basis: var(--deckColumnHeaderHeight); + min-height: var(--deckColumnHeaderHeight); + + > header.indicated { + box-shadow: 4px 0px var(--accent) inset; + } + } + + &.naked { + background: var(--acrylicBg) !important; + -webkit-backdrop-filter: var(--blur, blur(10px)); + backdrop-filter: var(--blur, blur(10px)); + + > header { + background: transparent; + box-shadow: none; + + > button { + color: var(--fg); + } + } + } + + &.paged { + background: var(--bg) !important; + } + + > header { + position: relative; + display: flex; + z-index: 2; + line-height: var(--deckColumnHeaderHeight); + height: var(--deckColumnHeaderHeight); + padding: 0 16px; + font-size: 0.9em; + color: var(--panelHeaderFg); + background: var(--panelHeaderBg); + box-shadow: 0 1px 0 0 var(--panelHeaderDivider); + cursor: pointer; + + &, * { + user-select: none; + } + + &.indicated { + box-shadow: 0 3px 0 0 var(--accent); + } + + > .header { + display: inline-block; + align-items: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + > span:only-of-type { + width: 100%; + } + + > .toggleActive, + > .action > ::v-deep(*), + > .menu { + z-index: 1; + width: var(--deckColumnHeaderHeight); + line-height: var(--deckColumnHeaderHeight); + color: var(--faceTextButton); + + &:hover { + color: var(--faceTextButtonHover); + } + + &:active { + color: var(--faceTextButtonActive); + } + } + + > .toggleActive, > .action { + margin-left: -16px; + } + + > .action { + z-index: 1; + } + + > .action:empty { + display: none; + } + + > .menu { + margin-left: auto; + margin-right: -16px; + } + } + + > div { + height: calc(100% - var(--deckColumnHeaderHeight)); + overflow-y: auto; + overflow-x: hidden; // Safari does not supports clip + overflow-x: clip; + -webkit-overflow-scrolling: touch; + box-sizing: border-box; + background-color: var(--bg); + } +} +</style> diff --git a/packages/frontend/src/ui/deck/deck-store.ts b/packages/frontend/src/ui/deck/deck-store.ts new file mode 100644 index 0000000000..56db7398e5 --- /dev/null +++ b/packages/frontend/src/ui/deck/deck-store.ts @@ -0,0 +1,296 @@ +import { throttle } from 'throttle-debounce'; +import { markRaw } from 'vue'; +import { notificationTypes } from 'misskey-js'; +import { Storage } from '../../pizzax'; +import { i18n } from '@/i18n'; +import { api } from '@/os'; +import { deepClone } from '@/scripts/clone'; + +type ColumnWidget = { + name: string; + id: string; + data: Record<string, any>; +}; + +export type Column = { + id: string; + type: 'main' | 'widgets' | 'notifications' | 'tl' | 'antenna' | 'list' | 'mentions' | 'direct'; + name: string | null; + width: number; + widgets?: ColumnWidget[]; + active?: boolean; + flexible?: boolean; + antennaId?: string; + listId?: string; + includingTypes?: typeof notificationTypes[number][]; + tl?: 'home' | 'local' | 'social' | 'global'; +}; + +export const deckStore = markRaw(new Storage('deck', { + profile: { + where: 'deviceAccount', + default: 'default', + }, + columns: { + where: 'deviceAccount', + default: [] as Column[], + }, + layout: { + where: 'deviceAccount', + default: [] as Column['id'][][], + }, + columnAlign: { + where: 'deviceAccount', + default: 'left' as 'left' | 'right' | 'center', + }, + alwaysShowMainColumn: { + where: 'deviceAccount', + default: true, + }, + navWindow: { + where: 'deviceAccount', + default: true, + }, +})); + +export const loadDeck = async () => { + let deck; + + try { + deck = await api('i/registry/get', { + scope: ['client', 'deck', 'profiles'], + key: deckStore.state.profile, + }); + } catch (err) { + if (err.code === 'NO_SUCH_KEY') { + // 後方互換性のため + if (deckStore.state.profile === 'default') { + saveDeck(); + return; + } + + deckStore.set('columns', []); + deckStore.set('layout', []); + return; + } + throw err; + } + + deckStore.set('columns', deck.columns); + deckStore.set('layout', deck.layout); +}; + +// TODO: deckがloadされていない状態でsaveすると意図せず上書きが発生するので対策する +export const saveDeck = throttle(1000, () => { + api('i/registry/set', { + scope: ['client', 'deck', 'profiles'], + key: deckStore.state.profile, + value: { + columns: deckStore.reactiveState.columns.value, + layout: deckStore.reactiveState.layout.value, + }, + }); +}); + +export async function getProfiles(): Promise<string[]> { + return await api('i/registry/keys', { + scope: ['client', 'deck', 'profiles'], + }); +} + +export async function deleteProfile(key: string): Promise<void> { + return await api('i/registry/remove', { + scope: ['client', 'deck', 'profiles'], + key: key, + }); +} + +export function addColumn(column: Column) { + if (column.name === undefined) column.name = null; + deckStore.push('columns', column); + deckStore.push('layout', [column.id]); + saveDeck(); +} + +export function removeColumn(id: Column['id']) { + deckStore.set('columns', deckStore.state.columns.filter(c => c.id !== id)); + deckStore.set('layout', deckStore.state.layout + .map(ids => ids.filter(_id => _id !== id)) + .filter(ids => ids.length > 0)); + saveDeck(); +} + +export function swapColumn(a: Column['id'], b: Column['id']) { + const aX = deckStore.state.layout.findIndex(ids => ids.indexOf(a) !== -1); + const aY = deckStore.state.layout[aX].findIndex(id => id === a); + const bX = deckStore.state.layout.findIndex(ids => ids.indexOf(b) !== -1); + const bY = deckStore.state.layout[bX].findIndex(id => id === b); + const layout = deepClone(deckStore.state.layout); + layout[aX][aY] = b; + layout[bX][bY] = a; + deckStore.set('layout', layout); + saveDeck(); +} + +export function swapLeftColumn(id: Column['id']) { + const layout = deepClone(deckStore.state.layout); + deckStore.state.layout.some((ids, i) => { + if (ids.includes(id)) { + const left = deckStore.state.layout[i - 1]; + if (left) { + layout[i - 1] = deckStore.state.layout[i]; + layout[i] = left; + deckStore.set('layout', layout); + } + return true; + } + }); + saveDeck(); +} + +export function swapRightColumn(id: Column['id']) { + const layout = deepClone(deckStore.state.layout); + deckStore.state.layout.some((ids, i) => { + if (ids.includes(id)) { + const right = deckStore.state.layout[i + 1]; + if (right) { + layout[i + 1] = deckStore.state.layout[i]; + layout[i] = right; + deckStore.set('layout', layout); + } + return true; + } + }); + saveDeck(); +} + +export function swapUpColumn(id: Column['id']) { + const layout = deepClone(deckStore.state.layout); + const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id)); + const ids = deepClone(deckStore.state.layout[idsIndex]); + ids.some((x, i) => { + if (x === id) { + const up = ids[i - 1]; + if (up) { + ids[i - 1] = id; + ids[i] = up; + + layout[idsIndex] = ids; + deckStore.set('layout', layout); + } + return true; + } + }); + saveDeck(); +} + +export function swapDownColumn(id: Column['id']) { + const layout = deepClone(deckStore.state.layout); + const idsIndex = deckStore.state.layout.findIndex(ids => ids.includes(id)); + const ids = deepClone(deckStore.state.layout[idsIndex]); + ids.some((x, i) => { + if (x === id) { + const down = ids[i + 1]; + if (down) { + ids[i + 1] = id; + ids[i] = down; + + layout[idsIndex] = ids; + deckStore.set('layout', layout); + } + return true; + } + }); + saveDeck(); +} + +export function stackLeftColumn(id: Column['id']) { + let layout = deepClone(deckStore.state.layout); + const i = deckStore.state.layout.findIndex(ids => ids.includes(id)); + layout = layout.map(ids => ids.filter(_id => _id !== id)); + layout[i - 1].push(id); + layout = layout.filter(ids => ids.length > 0); + deckStore.set('layout', layout); + saveDeck(); +} + +export function popRightColumn(id: Column['id']) { + let layout = deepClone(deckStore.state.layout); + const i = deckStore.state.layout.findIndex(ids => ids.includes(id)); + const affected = layout[i]; + layout = layout.map(ids => ids.filter(_id => _id !== id)); + layout.splice(i + 1, 0, [id]); + layout = layout.filter(ids => ids.length > 0); + deckStore.set('layout', layout); + + const columns = deepClone(deckStore.state.columns); + for (const column of columns) { + if (affected.includes(column.id)) { + column.active = true; + } + } + deckStore.set('columns', columns); + + saveDeck(); +} + +export function addColumnWidget(id: Column['id'], widget: ColumnWidget) { + const columns = deepClone(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const column = deepClone(deckStore.state.columns[columnIndex]); + if (column == null) return; + if (column.widgets == null) column.widgets = []; + column.widgets.unshift(widget); + columns[columnIndex] = column; + deckStore.set('columns', columns); + saveDeck(); +} + +export function removeColumnWidget(id: Column['id'], widget: ColumnWidget) { + const columns = deepClone(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const column = deepClone(deckStore.state.columns[columnIndex]); + if (column == null) return; + column.widgets = column.widgets.filter(w => w.id !== widget.id); + columns[columnIndex] = column; + deckStore.set('columns', columns); + saveDeck(); +} + +export function setColumnWidgets(id: Column['id'], widgets: ColumnWidget[]) { + const columns = deepClone(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const column = deepClone(deckStore.state.columns[columnIndex]); + if (column == null) return; + column.widgets = widgets; + columns[columnIndex] = column; + deckStore.set('columns', columns); + saveDeck(); +} + +export function updateColumnWidget(id: Column['id'], widgetId: string, widgetData: any) { + const columns = deepClone(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const column = deepClone(deckStore.state.columns[columnIndex]); + if (column == null) return; + column.widgets = column.widgets.map(w => w.id === widgetId ? { + ...w, + data: widgetData, + } : w); + columns[columnIndex] = column; + deckStore.set('columns', columns); + saveDeck(); +} + +export function updateColumn(id: Column['id'], column: Partial<Column>) { + const columns = deepClone(deckStore.state.columns); + const columnIndex = deckStore.state.columns.findIndex(c => c.id === id); + const currentColumn = deepClone(deckStore.state.columns[columnIndex]); + if (currentColumn == null) return; + for (const [k, v] of Object.entries(column)) { + currentColumn[k] = v; + } + columns[columnIndex] = currentColumn; + deckStore.set('columns', columns); + saveDeck(); +} diff --git a/packages/frontend/src/ui/deck/direct-column.vue b/packages/frontend/src/ui/deck/direct-column.vue new file mode 100644 index 0000000000..75b018cacd --- /dev/null +++ b/packages/frontend/src/ui/deck/direct-column.vue @@ -0,0 +1,31 @@ +<template> +<XColumn :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header><i class="ti ti-mail" style="margin-right: 8px;"></i>{{ column.name }}</template> + + <XNotes :pagination="pagination"/> +</XColumn> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XColumn from './column.vue'; +import XNotes from '@/components/MkNotes.vue'; +import { Column } from './deck-store'; + +defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +const pagination = { + endpoint: 'notes/mentions' as const, + limit: 10, + params: { + visibility: 'specified', + }, +}; +</script> diff --git a/packages/frontend/src/ui/deck/list-column.vue b/packages/frontend/src/ui/deck/list-column.vue new file mode 100644 index 0000000000..d9f3f7b4e7 --- /dev/null +++ b/packages/frontend/src/ui/deck/list-column.vue @@ -0,0 +1,58 @@ +<template> +<XColumn :menu="menu" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header> + <i class="ti ti-list"></i><span style="margin-left: 8px;">{{ column.name }}</span> + </template> + + <XTimeline v-if="column.listId" ref="timeline" src="list" :list="column.listId" @after="() => emit('loaded')"/> +</XColumn> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XColumn from './column.vue'; +import { updateColumn, Column } from './deck-store'; +import XTimeline from '@/components/MkTimeline.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'loaded'): void; + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +let timeline = $ref<InstanceType<typeof XTimeline>>(); + +if (props.column.listId == null) { + setList(); +} + +async function setList() { + const lists = await os.api('users/lists/list'); + const { canceled, result: list } = await os.select({ + title: i18n.ts.selectList, + items: lists.map(x => ({ + value: x, text: x.name, + })), + default: props.column.listId, + }); + if (canceled) return; + updateColumn(props.column.id, { + listId: list.id, + }); +} + +const menu = [{ + icon: 'ti ti-pencil', + text: i18n.ts.selectList, + action: setList, +}]; +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/frontend/src/ui/deck/main-column.vue b/packages/frontend/src/ui/deck/main-column.vue new file mode 100644 index 0000000000..0c66172397 --- /dev/null +++ b/packages/frontend/src/ui/deck/main-column.vue @@ -0,0 +1,68 @@ +<template> +<XColumn v-if="deckStore.state.alwaysShowMainColumn || mainRouter.currentRoute.value.name !== 'index'" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header> + <template v-if="pageMetadata?.value"> + <i :class="pageMetadata?.value.icon"></i> + {{ pageMetadata?.value.title }} + </template> + </template> + + <RouterView @contextmenu.stop="onContextmenu"/> +</XColumn> +</template> + +<script lang="ts" setup> +import { ComputedRef, provide } from 'vue'; +import XColumn from './column.vue'; +import { deckStore, Column } from '@/ui/deck/deck-store'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { mainRouter } from '@/router'; +import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; + +defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); + +provide('router', mainRouter); +provideMetadataReceiver((info) => { + pageMetadata = info; +}); + +/* +function back() { + history.back(); +} +*/ +function onContextmenu(ev: MouseEvent) { + if (!ev.target) return; + + const isLink = (el: HTMLElement) => { + if (el.tagName === 'A') return true; + if (el.parentElement) { + return isLink(el.parentElement); + } + }; + if (isLink(ev.target as HTMLElement)) return; + if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes((ev.target as HTMLElement).tagName) || (ev.target as HTMLElement).attributes['contenteditable']) return; + if (window.getSelection()?.toString() !== '') return; + const path = mainRouter.currentRoute.value.path; + os.contextMenu([{ + type: 'label', + text: path, + }, { + icon: 'ti ti-window-maximize', + text: i18n.ts.openInWindow, + action: () => { + os.pageWindow(path); + }, + }], ev); +} +</script> diff --git a/packages/frontend/src/ui/deck/mentions-column.vue b/packages/frontend/src/ui/deck/mentions-column.vue new file mode 100644 index 0000000000..16962956a0 --- /dev/null +++ b/packages/frontend/src/ui/deck/mentions-column.vue @@ -0,0 +1,28 @@ +<template> +<XColumn :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header><i class="ti ti-at" style="margin-right: 8px;"></i>{{ column.name }}</template> + + <XNotes :pagination="pagination"/> +</XColumn> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XColumn from './column.vue'; +import XNotes from '@/components/MkNotes.vue'; +import { Column } from './deck-store'; + +defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +const pagination = { + endpoint: 'notes/mentions' as const, + limit: 10, +}; +</script> diff --git a/packages/frontend/src/ui/deck/notifications-column.vue b/packages/frontend/src/ui/deck/notifications-column.vue new file mode 100644 index 0000000000..9d133035fe --- /dev/null +++ b/packages/frontend/src/ui/deck/notifications-column.vue @@ -0,0 +1,44 @@ +<template> +<XColumn :column="column" :is-stacked="isStacked" :menu="menu" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header><i class="ti ti-bell" style="margin-right: 8px;"></i>{{ column.name }}</template> + + <XNotifications :include-types="column.includingTypes"/> +</XColumn> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent } from 'vue'; +import XColumn from './column.vue'; +import { updateColumn, Column } from './deck-store'; +import XNotifications from '@/components/MkNotifications.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +function func() { + os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), { + includingTypes: props.column.includingTypes, + }, { + done: async (res) => { + const { includingTypes } = res; + updateColumn(props.column.id, { + includingTypes: includingTypes, + }); + }, + }, 'closed'); +} + +const menu = [{ + icon: 'ti ti-pencil', + text: i18n.ts.notificationSetting, + action: func, +}]; +</script> diff --git a/packages/frontend/src/ui/deck/tl-column.vue b/packages/frontend/src/ui/deck/tl-column.vue new file mode 100644 index 0000000000..49b29145ff --- /dev/null +++ b/packages/frontend/src/ui/deck/tl-column.vue @@ -0,0 +1,119 @@ +<template> +<XColumn :menu="menu" :column="column" :is-stacked="isStacked" :indicated="indicated" @change-active-state="onChangeActiveState" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header> + <i v-if="column.tl === 'home'" class="ti ti-home"></i> + <i v-else-if="column.tl === 'local'" class="ti ti-messages"></i> + <i v-else-if="column.tl === 'social'" class="ti ti-share"></i> + <i v-else-if="column.tl === 'global'" class="ti ti-world"></i> + <span style="margin-left: 8px;">{{ column.name }}</span> + </template> + + <div v-if="disabled" class="iwaalbte"> + <p> + <i class="ti ti-minus-circle"></i> + {{ $t('disabled-timeline.title') }} + </p> + <p class="desc">{{ $t('disabled-timeline.description') }}</p> + </div> + <XTimeline v-else-if="column.tl" ref="timeline" :key="column.tl" :src="column.tl" @after="() => emit('loaded')" @queue="queueUpdated" @note="onNote"/> +</XColumn> +</template> + +<script lang="ts" setup> +import { onMounted } from 'vue'; +import XColumn from './column.vue'; +import { removeColumn, updateColumn, Column } from './deck-store'; +import XTimeline from '@/components/MkTimeline.vue'; +import * as os from '@/os'; +import { $i } from '@/account'; +import { instance } from '@/instance'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'loaded'): void; + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +let disabled = $ref(false); +let indicated = $ref(false); +let columnActive = $ref(true); + +onMounted(() => { + if (props.column.tl == null) { + setType(); + } else if ($i) { + disabled = !$i.isModerator && !$i.isAdmin && ( + instance.disableLocalTimeline && ['local', 'social'].includes(props.column.tl) || + instance.disableGlobalTimeline && ['global'].includes(props.column.tl)); + } +}); + +async function setType() { + const { canceled, result: src } = await os.select({ + title: i18n.ts.timeline, + items: [{ + value: 'home' as const, text: i18n.ts._timelines.home, + }, { + value: 'local' as const, text: i18n.ts._timelines.local, + }, { + value: 'social' as const, text: i18n.ts._timelines.social, + }, { + value: 'global' as const, text: i18n.ts._timelines.global, + }], + }); + if (canceled) { + if (props.column.tl == null) { + removeColumn(props.column.id); + } + return; + } + updateColumn(props.column.id, { + tl: src, + }); +} + +function queueUpdated(q) { + if (columnActive) { + indicated = q !== 0; + } +} + +function onNote() { + if (!columnActive) { + indicated = true; + } +} + +function onChangeActiveState(state) { + columnActive = state; + + if (columnActive) { + indicated = false; + } +} + +const menu = [{ + icon: 'ti ti-pencil', + text: i18n.ts.timeline, + action: setType, +}]; +</script> + +<style lang="scss" scoped> +.iwaalbte { + text-align: center; + + > p { + margin: 16px; + + &.desc { + font-size: 14px; + } + } +} +</style> diff --git a/packages/frontend/src/ui/deck/widgets-column.vue b/packages/frontend/src/ui/deck/widgets-column.vue new file mode 100644 index 0000000000..fc61d18ff6 --- /dev/null +++ b/packages/frontend/src/ui/deck/widgets-column.vue @@ -0,0 +1,69 @@ +<template> +<XColumn :menu="menu" :naked="true" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> + <template #header><i class="ti ti-apps" style="margin-right: 8px;"></i>{{ column.name }}</template> + + <div class="wtdtxvec"> + <div v-if="!(column.widgets && column.widgets.length > 0) && !edit" class="intro">{{ i18n.ts._deck.widgetsIntroduction }}</div> + <XWidgets :edit="edit" :widgets="column.widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/> + </div> +</XColumn> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import XColumn from './column.vue'; +import { addColumnWidget, Column, removeColumnWidget, setColumnWidgets, updateColumnWidget } from './deck-store'; +import XWidgets from '@/components/MkWidgets.vue'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + column: Column; + isStacked: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; +}>(); + +let edit = $ref(false); + +function addWidget(widget) { + addColumnWidget(props.column.id, widget); +} + +function removeWidget(widget) { + removeColumnWidget(props.column.id, widget); +} + +function updateWidget({ id, data }) { + updateColumnWidget(props.column.id, id, data); +} + +function updateWidgets(widgets) { + setColumnWidgets(props.column.id, widgets); +} + +function func() { + edit = !edit; +} + +const menu = [{ + icon: 'ti ti-pencil', + text: i18n.ts.editWidgets, + action: func, +}]; +</script> + +<style lang="scss" scoped> +.wtdtxvec { + --margin: 8px; + --panelBorder: none; + + padding: 0 var(--margin); + + > .intro { + padding: 16px; + text-align: center; + } +} +</style> |