12.104.0あっぷだて

This commit is contained in:
nullnyat
2022-02-09 15:18:47 +09:00
202 changed files with 6022 additions and 3324 deletions

View File

@ -11,8 +11,8 @@
},
"dependencies": {
"@discordapp/twemoji": "13.1.0",
"@fortawesome/fontawesome-free": "6.0.0-beta3",
"@syuilo/aiscript": "0.11.1",
"@types/dateformat": "3.0.1",
"@types/escape-regexp": "0.0.1",
"@types/glob": "7.2.0",
"@types/gulp": "4.0.9",
@ -26,7 +26,6 @@
"@types/punycode": "2.1.0",
"@types/qrcode": "1.4.2",
"@types/random-seed": "0.3.3",
"@types/request-stats": "3.0.0",
"@types/seedrandom": "2.4.28",
"@types/throttle-debounce": "2.1.0",
"@types/tinycolor2": "1.4.3",
@ -47,6 +46,7 @@
"broadcast-channel": "4.9.0",
"chart.js": "3.7.0",
"chartjs-adapter-date-fns": "2.0.0",
"chartjs-plugin-gradient": "0.2.1",
"chartjs-plugin-zoom": "1.2.0",
"compare-versions": "4.1.3",
"content-disposition": "0.5.4",
@ -87,7 +87,6 @@
"querystring": "0.2.1",
"random-seed": "0.3.0",
"reflect-metadata": "0.1.13",
"request-stats": "3.0.0",
"rndstr": "1.0.0",
"s-age": "1.1.2",
"sass": "1.49.0",

View File

@ -1,5 +1,5 @@
<template>
<MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :max-width="340" @closed="emit('closed')">
<MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :max-width="340" :direction="'left'" :inner-margin="16" @closed="emit('closed')">
<div v-if="title" class="qpcyisrl">
<div class="title">{{ title }}</div>
<div v-for="x in series" class="series">

View File

@ -29,6 +29,7 @@ import {
import 'chartjs-adapter-date-fns';
import { enUS } from 'date-fns/locale';
import zoomPlugin from 'chartjs-plugin-zoom';
import gradient from 'chartjs-plugin-gradient';
import * as os from '@/os';
import { defaultStore } from '@/store';
import MkChartTooltip from '@/components/chart-tooltip.vue';
@ -49,6 +50,7 @@ Chart.register(
SubTitle,
Filler,
zoomPlugin,
gradient,
);
const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
@ -61,9 +63,17 @@ const alpha = (hex, a) => {
return `rgba(${r}, ${g}, ${b}, ${a})`;
};
const colors = ['#008FFB', '#00E396', '#FEB019', '#FF4560'];
const colors = {
blue: '#008FFB',
green: '#00E396',
yellow: '#FEB019',
red: '#FF4560',
purple: '#e300db',
orange: '#fe6919',
};
const colorSets = [colors.blue, colors.green, colors.yellow, colors.red, colors.purple];
const getColor = (i) => {
return colors[i % colors.length];
return colorSets[i % colorSets.length];
};
export default defineComponent({
@ -95,6 +105,11 @@ export default defineComponent({
required: false,
default: false
},
bar: {
type: Boolean,
required: false,
default: false
},
aspectRatio: {
type: Number,
required: false,
@ -186,22 +201,36 @@ export default defineComponent({
// フォントカラー
Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg');
const maxes = data.series.map((x, i) => Math.max(...x.data.map(d => d.y)));
chartInstance = new Chart(chartEl.value, {
type: 'line',
type: props.bar ? 'bar' : 'line',
data: {
labels: new Array(props.limit).fill(0).map((_, i) => getDate(i).toLocaleString()).slice().reverse(),
datasets: data.series.map((x, i) => ({
parsing: false,
label: x.name,
data: x.data.slice().reverse(),
tension: 0.3,
pointRadius: 0,
tension: 0,
borderWidth: 2,
borderColor: x.color ? x.color : getColor(i),
borderDash: x.borderDash || [],
borderJoinStyle: 'round',
backgroundColor: alpha(x.color ? x.color : getColor(i), 0.1),
gradient: {
backgroundColor: {
axis: 'y',
colors: {
0: alpha(x.color ? x.color : getColor(i), 0),
[maxes[i]]: alpha(x.color ? x.color : getColor(i), 0.1),
},
},
},
barPercentage: 0.9,
categoryPercentage: 0.9,
fill: x.type === 'area',
clip: 8,
hidden: !!x.hidden,
})),
},
@ -210,7 +239,7 @@ export default defineComponent({
layout: {
padding: {
left: 0,
right: 0,
right: 8,
top: 0,
bottom: 0,
},
@ -218,6 +247,7 @@ export default defineComponent({
scales: {
x: {
type: 'time',
stacked: props.stacked,
time: {
stepSize: 1,
unit: props.span === 'day' ? 'month' : 'day',
@ -228,6 +258,8 @@ export default defineComponent({
},
ticks: {
display: props.detailed,
maxRotation: 0,
autoSkipPadding: 16,
},
adapters: {
date: {
@ -245,12 +277,21 @@ export default defineComponent({
},
ticks: {
display: props.detailed,
//mirror: true,
},
},
},
interaction: {
intersect: false,
mode: 'index',
},
elements: {
point: {
hoverRadius: 5,
hoverBorderWidth: 2,
},
},
animation: false,
plugins: {
legend: {
display: props.detailed,
@ -294,6 +335,7 @@ export default defineComponent({
},
}
},
gradient,
},
},
plugins: [{
@ -324,20 +366,60 @@ export default defineComponent({
// TODO
};
const fetchFederationInstancesChart = async (total: boolean): Promise<typeof data> => {
const fetchFederationChart = async (): Promise<typeof data> => {
const raw = await os.api('charts/federation', { limit: props.limit, span: props.span });
return {
series: [{
name: 'Instances',
name: 'Total',
type: 'area',
data: format(total
? raw.instance.total
: sum(raw.instance.inc, negate(raw.instance.dec))
),
data: format(raw.instance.total),
color: '#888888',
}, {
name: 'Inc/Dec',
type: 'area',
data: format(sum(raw.instance.inc, negate(raw.instance.dec))),
color: colors.purple,
}, {
name: 'Received',
type: 'area',
data: format(raw.inboxInstances),
color: colors.blue,
}, {
name: 'Delivered',
type: 'area',
data: format(raw.deliveredInstances),
color: colors.green,
}, {
name: 'Stalled',
type: 'area',
data: format(raw.stalled),
color: colors.red,
}],
};
};
const fetchApRequestChart = async (): Promise<typeof data> => {
const raw = await os.api('charts/ap-request', { limit: props.limit, span: props.span });
return {
series: [{
name: 'In',
type: 'area',
color: '#008FFB',
data: format(raw.inboxReceived)
}, {
name: 'Out (succ)',
type: 'area',
color: '#00E396',
data: format(raw.deliverSucceeded)
}, {
name: 'Out (fail)',
type: 'area',
color: '#FEB019',
data: format(raw.deliverFailed)
}]
};
};
const fetchNotesChart = async (type: string): Promise<typeof data> => {
const raw = await os.api('charts/notes', { limit: props.limit, span: props.span });
return {
@ -349,6 +431,7 @@ export default defineComponent({
? sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec))
: sum(raw[type].inc, negate(raw[type].dec))
),
color: '#888888',
}, {
name: 'Renotes',
type: 'area',
@ -356,6 +439,7 @@ export default defineComponent({
? sum(raw.local.diffs.renote, raw.remote.diffs.renote)
: raw[type].diffs.renote
),
color: colors.green,
}, {
name: 'Replies',
type: 'area',
@ -363,6 +447,7 @@ export default defineComponent({
? sum(raw.local.diffs.reply, raw.remote.diffs.reply)
: raw[type].diffs.reply
),
color: colors.yellow,
}, {
name: 'Normal',
type: 'area',
@ -370,6 +455,15 @@ export default defineComponent({
? sum(raw.local.diffs.normal, raw.remote.diffs.normal)
: raw[type].diffs.normal
),
color: colors.blue,
}, {
name: 'With file',
type: 'area',
data: format(type == 'combined'
? sum(raw.local.diffs.withFile, raw.remote.diffs.withFile)
: raw[type].diffs.withFile
),
color: colors.purple,
}],
};
};
@ -425,17 +519,50 @@ export default defineComponent({
const raw = await os.api('charts/active-users', { limit: props.limit, span: props.span });
return {
series: [{
name: 'Combined',
type: 'line',
data: format(sum(raw.local.users, raw.remote.users)),
}, {
name: 'Local',
name: 'Read & Write',
type: 'area',
data: format(raw.local.users),
data: format(raw.readWrite),
color: colors.orange,
}, {
name: 'Remote',
name: 'Write',
type: 'area',
data: format(raw.remote.users),
data: format(raw.write),
color: colors.blue,
}, {
name: 'Read',
type: 'area',
data: format(raw.read),
color: '#888888',
}, {
name: '< Week',
type: 'area',
data: format(raw.registeredWithinWeek),
color: colors.green,
}, {
name: '< Month',
type: 'area',
data: format(raw.registeredWithinMonth),
color: colors.yellow,
}, {
name: '< Year',
type: 'area',
data: format(raw.registeredWithinYear),
color: colors.red,
}, {
name: '> Week',
type: 'area',
data: format(raw.registeredOutsideWeek),
color: colors.yellow,
}, {
name: '> Month',
type: 'area',
data: format(raw.registeredOutsideMonth),
color: colors.red,
}, {
name: '> Year',
type: 'area',
data: format(raw.registeredOutsideYear),
color: colors.purple,
}],
};
};
@ -476,26 +603,6 @@ export default defineComponent({
};
};
const fetchDriveTotalChart = async (): Promise<typeof data> => {
const raw = await os.api('charts/drive', { limit: props.limit, span: props.span });
return {
bytes: true,
series: [{
name: 'Combined',
type: 'line',
data: format(sum(raw.local.totalSize, raw.remote.totalSize)),
}, {
name: 'Local',
type: 'area',
data: format(raw.local.totalSize),
}, {
name: 'Remote',
type: 'area',
data: format(raw.remote.totalSize),
}],
};
};
const fetchDriveFilesChart = async (): Promise<typeof data> => {
const raw = await os.api('charts/drive', { limit: props.limit, span: props.span });
return {
@ -531,25 +638,6 @@ export default defineComponent({
};
};
const fetchDriveFilesTotalChart = async (): Promise<typeof data> => {
const raw = await os.api('charts/drive', { limit: props.limit, span: props.span });
return {
series: [{
name: 'Combined',
type: 'line',
data: format(sum(raw.local.totalCount, raw.remote.totalCount)),
}, {
name: 'Local',
type: 'area',
data: format(raw.local.totalCount),
}, {
name: 'Remote',
type: 'area',
data: format(raw.remote.totalCount),
}],
};
};
const fetchInstanceRequestsChart = async (): Promise<typeof data> => {
const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span });
return {
@ -680,11 +768,26 @@ export default defineComponent({
};
};
const fetchPerUserDriveChart = async (): Promise<typeof data> => {
const raw = await os.api('charts/user/drive', { userId: props.args.user.id, limit: props.limit, span: props.span });
return {
series: [{
name: 'Inc',
type: 'area',
data: format(raw.incSize),
}, {
name: 'Dec',
type: 'area',
data: format(raw.decSize),
}],
};
};
const fetchAndRender = async () => {
const fetchData = () => {
switch (props.src) {
case 'federation-instances': return fetchFederationInstancesChart(false);
case 'federation-instances-total': return fetchFederationInstancesChart(true);
case 'federation': return fetchFederationChart();
case 'ap-request': return fetchApRequestChart();
case 'users': return fetchUsersChart(false);
case 'users-total': return fetchUsersChart(true);
case 'active-users': return fetchActiveUsersChart();
@ -693,9 +796,7 @@ export default defineComponent({
case 'remote-notes': return fetchNotesChart('remote');
case 'notes-total': return fetchNotesTotalChart();
case 'drive': return fetchDriveChart();
case 'drive-total': return fetchDriveTotalChart();
case 'drive-files': return fetchDriveFilesChart();
case 'drive-files-total': return fetchDriveFilesTotalChart();
case 'instance-requests': return fetchInstanceRequestsChart();
case 'instance-users': return fetchInstanceUsersChart(false);
@ -710,6 +811,7 @@ export default defineComponent({
case 'instance-drive-files-total': return fetchInstanceDriveFilesChart(true);
case 'per-user-notes': return fetchPerUserNotesChart();
case 'per-user-drive': return fetchPerUserDriveChart();
}
};
fetching.value = true;

View File

@ -81,7 +81,7 @@ import { getStaticImageUrl } from '@/scripts/get-static-image-url';
import Ripple from '@/components/ripple.vue';
import * as os from '@/os';
import { isTouchUsing } from '@/scripts/touch';
import { isMobile } from '@/scripts/is-mobile';
import { deviceKind } from '@/scripts/device-kind';
import { emojiCategories, instance } from '@/instance';
import XSection from './emoji-picker.section.vue';
import { i18n } from '@/i18n';
@ -263,7 +263,7 @@ watch(q, () => {
});
function focus() {
if (!isMobile && !isTouchUsing) {
if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) {
search.value?.focus({
preventScroll: true
});

View File

@ -3,8 +3,8 @@
<div class="selects" style="display: flex;">
<MkSelect v-model="chartSrc" style="margin: 0; flex: 1;">
<optgroup :label="$ts.federation">
<option value="federation-instances">{{ $ts._charts.federationInstancesIncDec }}</option>
<option value="federation-instances-total">{{ $ts._charts.federationInstancesTotal }}</option>
<option value="federation">{{ $ts._charts.federation }}</option>
<option value="ap-request">{{ $ts._charts.apRequest }}</option>
</optgroup>
<optgroup :label="$ts.users">
<option value="users">{{ $ts._charts.usersIncDec }}</option>
@ -19,9 +19,7 @@
</optgroup>
<optgroup :label="$ts.drive">
<option value="drive-files">{{ $ts._charts.filesIncDec }}</option>
<option value="drive-files-total">{{ $ts._charts.filesTotal }}</option>
<option value="drive">{{ $ts._charts.storageUsageIncDec }}</option>
<option value="drive-total">{{ $ts._charts.storageUsageTotal }}</option>
</optgroup>
</MkSelect>
<MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;">

View File

@ -7,15 +7,27 @@
<script lang="ts" setup>
import { } from 'vue';
import { instanceName } from '@/config';
const props = defineProps<{
instance: any; // TODO
instance?: {
faviconUrl?: string
name: string
themeColor?: string
}
}>();
const themeColor = props.instance.themeColor || '#777777';
// if no instance data is given, this is for the local instance
const instance = props.instance ?? {
faviconUrl: '/favicon.ico',
name: instanceName,
themeColor: (document.querySelector('meta[name="theme-color-orig"]') as HTMLMetaElement)?.content
};
const themeColor = instance.themeColor ?? '#777777';
const bg = {
background: `linear-gradient(90deg, ${themeColor}, ${themeColor + '00'})`
background: `linear-gradient(90deg, ${themeColor}, ${themeColor}00)`
};
</script>

View File

@ -339,6 +339,7 @@ function addTag(tag: string) {
function focus() {
textareaEl.focus();
textareaEl.setSelectionRange(textareaEl.value.length, textareaEl.value.length);
}
function chooseFileFrom(ev) {

View File

@ -14,6 +14,7 @@ import { nextTick, onMounted, computed, ref, watch, provide } from 'vue';
import * as os from '@/os';
import { isTouchUsing } from '@/scripts/touch';
import { defaultStore } from '@/store';
import { deviceKind } from '@/scripts/device-kind';
function getFixedContainer(el: Element | null): Element | null {
if (el == null || el.tagName === 'BODY') return null;
@ -62,7 +63,7 @@ const content = ref<HTMLElement>();
const zIndex = os.claimZIndex(props.zPriority);
const type = computed(() => {
if (props.preferType === 'auto') {
if (!defaultStore.state.disableDrawer && isTouchUsing && window.innerWidth < 500 && window.innerHeight < 1000) {
if (!defaultStore.state.disableDrawer && isTouchUsing && deviceKind === 'smartphone') {
return 'drawer';
} else {
return props.src != null ? 'popup' : 'dialog';

View File

@ -17,8 +17,12 @@ const props = withDefaults(defineProps<{
y?: number;
text?: string;
maxWidth?: number;
direction?: 'top' | 'bottom' | 'right' | 'left';
innerMargin?: number;
}>(), {
maxWidth: 250,
direction: 'top',
innerMargin: 0,
});
const emit = defineEmits<{
@ -34,39 +38,144 @@ const setPosition = () => {
const contentWidth = el.value.offsetWidth;
const contentHeight = el.value.offsetHeight;
let left: number;
let top: number;
let rect: DOMRect;
if (props.targetElement) {
rect = props.targetElement.getBoundingClientRect();
left = rect.left + window.pageXOffset + (props.targetElement.offsetWidth / 2);
top = rect.top + window.pageYOffset - contentHeight;
el.value.style.transformOrigin = 'center bottom';
} else {
left = props.x;
top = props.y - contentHeight;
}
left -= (el.value.offsetWidth / 2);
const calcPosWhenTop = () => {
let left: number;
let top: number;
if (left + contentWidth - window.pageXOffset > window.innerWidth) {
left = window.innerWidth - contentWidth + window.pageXOffset - 1;
}
// ツールチップを上に向かって表示するスペースがなければ下に向かって出す
if (top - window.pageYOffset < 0) {
if (props.targetElement) {
top = rect.top + window.pageYOffset + props.targetElement.offsetHeight;
el.value.style.transformOrigin = 'center top';
left = rect.left + window.pageXOffset + (props.targetElement.offsetWidth / 2);
top = (rect.top + window.pageYOffset - contentHeight) - props.innerMargin;
} else {
left = props.x;
top = (props.y - contentHeight) - props.innerMargin;
}
left -= (el.value.offsetWidth / 2);
if (left + contentWidth - window.pageXOffset > window.innerWidth) {
left = window.innerWidth - contentWidth + window.pageXOffset - 1;
}
return [left, top];
}
const calcPosWhenBottom = () => {
let left: number;
let top: number;
if (props.targetElement) {
left = rect.left + window.pageXOffset + (props.targetElement.offsetWidth / 2);
top = (rect.top + window.pageYOffset + props.targetElement.offsetHeight) + props.innerMargin;
} else {
left = props.x;
top = (props.y) + props.innerMargin;
}
left -= (el.value.offsetWidth / 2);
if (left + contentWidth - window.pageXOffset > window.innerWidth) {
left = window.innerWidth - contentWidth + window.pageXOffset - 1;
}
return [left, top];
}
const calcPosWhenLeft = () => {
let left: number;
let top: number;
if (props.targetElement) {
left = (rect.left + window.pageXOffset - contentWidth) - props.innerMargin;
top = rect.top + window.pageYOffset + (props.targetElement.offsetHeight / 2);
} else {
left = (props.x - contentWidth) - props.innerMargin;
top = props.y;
}
top -= (el.value.offsetHeight / 2);
if (top + contentHeight - window.pageYOffset > window.innerHeight) {
top = window.innerHeight - contentHeight + window.pageYOffset - 1;
}
return [left, top];
}
const calcPosWhenRight = () => {
let left: number;
let top: number;
if (props.targetElement) {
left = (rect.left + window.pageXOffset) + props.innerMargin;
top = rect.top + window.pageYOffset + (props.targetElement.offsetHeight / 2);
} else {
left = props.x + props.innerMargin;
top = props.y;
}
top -= (el.value.offsetHeight / 2);
if (top + contentHeight - window.pageYOffset > window.innerHeight) {
top = window.innerHeight - contentHeight + window.pageYOffset - 1;
}
return [left, top];
}
const calc = (): {
left: number;
top: number;
transformOrigin: string;
} => {
switch (props.direction) {
case 'top': {
const [left, top] = calcPosWhenTop();
// ツールチップを上に向かって表示するスペースがなければ下に向かって出す
if (top - window.pageYOffset < 0) {
const [left, top] = calcPosWhenBottom();
return { left, top, transformOrigin: 'center top' };
}
return { left, top, transformOrigin: 'center bottom' };
}
case 'bottom': {
const [left, top] = calcPosWhenBottom();
// TODO: ツールチップを下に向かって表示するスペースがなければ上に向かって出す
return { left, top, transformOrigin: 'center top' };
}
case 'left': {
const [left, top] = calcPosWhenLeft();
// ツールチップを左に向かって表示するスペースがなければ右に向かって出す
if (left - window.pageXOffset < 0) {
const [left, top] = calcPosWhenRight();
return { left, top, transformOrigin: 'left center' };
}
return { left, top, transformOrigin: 'right center' };
}
case 'right': {
const [left, top] = calcPosWhenRight();
// TODO: ツールチップを右に向かって表示するスペースがなければ左に向かって出す
return { left, top, transformOrigin: 'left center' };
}
}
return null as never;
}
const { left, top, transformOrigin } = calc();
el.value.style.transformOrigin = transformOrigin;
el.value.style.left = left + 'px';
el.value.style.top = top + 'px';
};

View File

@ -1,34 +1,55 @@
import { Directive } from 'vue';
const mountings = new Map<Element, {
resize: ResizeObserver;
intersection?: IntersectionObserver;
fn: (w: number, h: number) => void;
}>();
function calc(src: Element) {
const info = mountings.get(src);
const height = src.clientHeight;
const width = src.clientWidth;
if (!info) return;
// アクティベート前などでsrcが描画されていない場合
if (!height) {
// IntersectionObserverで表示検出する
if (!info.intersection) {
info.intersection = new IntersectionObserver(entries => {
if (entries.some(entry => entry.isIntersecting)) calc(src);
});
}
info.intersection.observe(src);
return;
}
if (info.intersection) {
info.intersection.disconnect()
delete info.intersection;
};
info.fn(width, height);
};
export default {
mounted(src, binding, vn) {
const calc = () => {
const height = src.clientHeight;
const width = src.clientWidth;
// 要素が(一時的に)DOMに存在しないときは計算スキップ
if (height === 0) return;
binding.value(width, height);
};
calc();
// Vue3では使えなくなった
// 無くても大丈夫か...
// TODO: ↑大丈夫じゃなかったので解決策を探す
//vn.context.$on('hook:activated', calc);
const ro = new ResizeObserver((entries, observer) => {
calc();
const resize = new ResizeObserver((entries, observer) => {
calc(src);
});
ro.observe(src);
resize.observe(src);
src._get_size_ro_ = ro;
mountings.set(src, { resize, fn: binding.value, });
calc(src);
},
unmounted(src, binding, vn) {
binding.value(0, 0);
src._get_size_ro_.unobserve(src);
const info = mountings.get(src);
if (!info) return;
info.resize.disconnect();
if (info.intersection) info.intersection.disconnect();
mountings.delete(src);
}
} as Directive;
} as Directive<Element, (w: number, h: number) => void>;

View File

@ -1,68 +1,107 @@
import { Directive } from 'vue';
type Value = { max?: number[]; min?: number[]; };
//const observers = new Map<Element, ResizeObserver>();
const mountings = new Map<Element, {
value: Value;
resize: ResizeObserver;
intersection?: IntersectionObserver;
previousWidth: number;
}>();
type ClassOrder = {
add: string[];
remove: string[];
};
const cache = new Map<string, ClassOrder>();
function getClassOrder(width: number, queue: Value): ClassOrder {
const getMaxClass = (v: number) => `max-width_${v}px`;
const getMinClass = (v: number) => `min-width_${v}px`;
return {
add: [
...(queue.max ? queue.max.filter(v => width <= v).map(getMaxClass) : []),
...(queue.min ? queue.min.filter(v => width >= v).map(getMinClass) : []),
],
remove: [
...(queue.max ? queue.max.filter(v => width > v).map(getMaxClass) : []),
...(queue.min ? queue.min.filter(v => width < v).map(getMinClass) : []),
]
};
}
function applyClassOrder(el: Element, order: ClassOrder) {
el.classList.add(...order.add);
el.classList.remove(...order.remove);
}
function getOrderName(width: number, queue: Value): string {
return `${width}|${queue.max ? queue.max.join(',') : ''}|${queue.min ? queue.min.join(',') : ''}`;
}
function calc(el: Element) {
const info = mountings.get(el);
const width = el.clientWidth;
if (!info || info.previousWidth === width) return;
// アクティベート前などでsrcが描画されていない場合
if (!width) {
// IntersectionObserverで表示検出する
if (!info.intersection) {
info.intersection = new IntersectionObserver(entries => {
if (entries.some(entry => entry.isIntersecting)) calc(el);
});
}
info.intersection.observe(el);
return;
}
if (info.intersection) {
info.intersection.disconnect()
delete info.intersection;
};
mountings.set(el, Object.assign(info, { previousWidth: width }));
const cached = cache.get(getOrderName(width, info.value));
if (cached) {
applyClassOrder(el, cached);
} else {
const order = getClassOrder(width, info.value);
cache.set(getOrderName(width, info.value), order);
applyClassOrder(el, order);
}
}
export default {
mounted(src, binding, vn) {
const query = binding.value;
const resize = new ResizeObserver((entries, observer) => {
calc(src);
});
const addClass = (el: Element, cls: string) => {
el.classList.add(cls);
};
mountings.set(src, {
value: binding.value,
resize,
previousWidth: 0,
});
const removeClass = (el: Element, cls: string) => {
el.classList.remove(cls);
};
calc(src);
resize.observe(src);
},
const calc = () => {
const width = src.clientWidth;
// 要素が(一時的に)DOMに存在しないときは計算スキップ
if (width === 0) return;
if (query.max) {
for (const v of query.max) {
if (width <= v) {
addClass(src, 'max-width_' + v + 'px');
} else {
removeClass(src, 'max-width_' + v + 'px');
}
}
}
if (query.min) {
for (const v of query.min) {
if (width >= v) {
addClass(src, 'min-width_' + v + 'px');
} else {
removeClass(src, 'min-width_' + v + 'px');
}
}
}
};
calc();
window.addEventListener('resize', calc);
// Vue3では使えなくなった
// 無くても大丈夫か...
// TODO: ↑大丈夫じゃなかったので解決策を探す
//vn.context.$on('hook:activated', calc);
//const ro = new ResizeObserver((entries, observer) => {
// calc();
//});
//ro.observe(el);
// TODO: 新たにプロパティを作るのをやめMapを使う
// ただメモリ的には↓の方が省メモリかもしれないので検討中
//el._ro_ = ro;
src._calc_ = calc;
updated(src, binding, vn) {
mountings.set(src, Object.assign({}, mountings.get(src), { value: binding.value }));
calc(src);
},
unmounted(src, binding, vn) {
//el._ro_.unobserve(el);
window.removeEventListener('resize', src._calc_);
const info = mountings.get(src);
if (!info) return;
info.resize.disconnect();
if (info.intersection) info.intersection.disconnect();
mountings.delete(src);
}
} as Directive;
} as Directive<Element, Value>;

View File

@ -14,7 +14,7 @@ if (localStorage.getItem('accounts') != null) {
//#endregion
import { computed, createApp, watch, markRaw, version as vueVersion } from 'vue';
import * as compareVersions from 'compare-versions';
import compareVersions from 'compare-versions';
import widgets from '@/widgets';
import directives from '@/directives';
@ -32,7 +32,7 @@ import { defaultStore, ColdDeviceStorage } from '@/store';
import { fetchInstance, instance } from '@/instance';
import { makeHotkey } from '@/scripts/hotkey';
import { search } from '@/scripts/search';
import { isMobile } from '@/scripts/is-mobile';
import { deviceKind } from '@/scripts/device-kind';
import { initializeSw } from '@/scripts/initialize-sw';
import { reloadChannel } from '@/scripts/unison-reload';
import { reactionPicker } from '@/scripts/reaction-picker';
@ -92,7 +92,7 @@ window.addEventListener('resize', () => {
//#endregion
// If mobile, insert the viewport meta tag
if (isMobile || window.innerWidth <= 1024) {
if (['smartphone', 'tablet'].includes(deviceKind)) {
const viewport = document.getElementsByName('viewport').item(0);
viewport.setAttribute('content',
`${viewport.getAttribute('content')},minimum-scale=1,maximum-scale=1,user-scalable=no`);

View File

@ -1,5 +1,5 @@
<template>
<MkSpacer :content-max="600" :margin-min="20">
<MkSpacer v-if="tab === 'overview'" :content-max="600" :margin-min="20">
<div class="_formRoot">
<div class="_formBlock fwhjspax" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }">
<div class="content">
@ -65,35 +65,50 @@
</FormSection>
</div>
</MkSpacer>
<MkSpacer v-else-if="tab === 'charts'" :content-max="1200" :margin-min="20">
<MkInstanceStats :chart-limit="500" :detailed="true"/>
</MkSpacer>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { ref, computed } from 'vue';
import { version, instanceName } from '@/config';
import FormLink from '@/components/form/link.vue';
import FormSection from '@/components/form/section.vue';
import FormSuspense from '@/components/form/suspense.vue';
import FormSplit from '@/components/form/split.vue';
import MkKeyValue from '@/components/key-value.vue';
import MkInstanceStats from '@/components/instance-stats.vue';
import * as os from '@/os';
import number from '@/filters/number';
import * as symbols from '@/symbols';
import { host } from '@/config';
import { i18n } from '@/i18n';
const stats = ref(null);
let stats = $ref(null);
let tab = $ref('overview');
const initStats = () => os.api('stats', {
}).then((res) => {
stats.value = res;
stats = res;
});
defineExpose({
[symbols.PAGE_INFO]: {
[symbols.PAGE_INFO]: computed(() => ({
title: i18n.ts.instanceInfo,
icon: 'fas fa-info-circle',
bg: 'var(--bg)',
},
tabs: [{
active: tab === 'overview',
title: i18n.ts.overview,
onClick: () => { tab = 'overview'; },
}, {
active: tab === 'charts',
title: i18n.ts.charts,
icon: 'fas fa-chart-bar',
onClick: () => { tab = 'charts'; },
},],
})),
});
</script>

View File

@ -28,7 +28,7 @@
<template #label>MIME type</template>
</MkInput>
</div>
<MkPagination v-slot="{items}" ref="files" :pagination="pagination" class="urempief">
<MkPagination v-slot="{items}" :pagination="pagination" class="urempief">
<button v-for="file in items" :key="file.id" class="file _panel _button _gap" @click="show(file, $event)">
<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
<div class="body">
@ -54,8 +54,8 @@
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from 'vue';
<script lang="ts" setup>
import { computed } from 'vue';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import MkSelect from '@/components/form/select.vue';
@ -65,80 +65,63 @@ import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
import bytes from '@/filters/bytes';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { i18n } from '@/i18n';
export default defineComponent({
components: {
MkButton,
MkInput,
MkSelect,
MkPagination,
MkContainer,
MkDriveFileThumbnail,
},
let q = $ref(null);
let origin = $ref('local');
let type = $ref(null);
let searchHost = $ref('');
const pagination = {
endpoint: 'admin/drive/files' as const,
limit: 10,
params: computed(() => ({
type: (type && type !== '') ? type : null,
origin: origin,
hostname: (searchHost && searchHost !== '') ? searchHost : null,
})),
};
emits: ['info'],
function clear() {
os.confirm({
type: 'warning',
text: i18n.ts.clearCachedFilesConfirm,
}).then(({ canceled }) => {
if (canceled) return;
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.files,
icon: 'fas fa-cloud',
bg: 'var(--bg)',
actions: [{
text: this.$ts.clearCachedFiles,
icon: 'fas fa-trash-alt',
handler: this.clear
}]
},
q: null,
origin: 'local',
type: null,
searchHost: '',
pagination: {
endpoint: 'admin/drive/files' as const,
limit: 10,
params: computed(() => ({
type: (this.type && this.type !== '') ? this.type : null,
origin: this.origin,
hostname: (this.searchHost && this.searchHost !== '') ? this.searchHost : null,
})),
},
os.apiWithDialog('admin/drive/clean-remote-files', {});
});
}
function show(file) {
os.popup(import('./file-dialog.vue'), {
fileId: file.id
}, {}, 'closed');
}
function find() {
os.api('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => {
show(file);
}).catch(err => {
if (err.code === 'NO_SUCH_FILE') {
os.alert({
type: 'error',
text: i18n.ts.notFound
});
}
},
});
}
methods: {
clear() {
os.confirm({
type: 'warning',
text: this.$ts.clearCachedFilesConfirm,
}).then(({ canceled }) => {
if (canceled) return;
os.apiWithDialog('admin/drive/clean-remote-files', {});
});
},
show(file, ev) {
os.popup(import('./file-dialog.vue'), {
fileId: file.id
}, {}, 'closed');
},
find() {
os.api('admin/drive/show-file', this.q.startsWith('http://') || this.q.startsWith('https://') ? { url: this.q.trim() } : { fileId: this.q.trim() }).then(file => {
this.show(file);
}).catch(e => {
if (e.code === 'NO_SUCH_FILE') {
os.alert({
type: 'error',
text: this.$ts.notFound
});
}
});
},
bytes
}
defineExpose({
[symbols.PAGE_INFO]: computed(() => ({
title: i18n.ts.files,
icon: 'fas fa-cloud',
bg: 'var(--bg)',
actions: [{
text: i18n.ts.clearCachedFiles,
icon: 'fas fa-trash-alt',
handler: clear,
}],
})),
});
</script>

View File

@ -1,239 +1,223 @@
<template>
<div class="lknzcolw">
<div class="users">
<div class="inputs">
<MkSelect v-model="sort" style="flex: 1;">
<template #label>{{ $ts.sort }}</template>
<option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option>
<option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option>
<option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option>
<option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option>
</MkSelect>
<MkSelect v-model="state" style="flex: 1;">
<template #label>{{ $ts.state }}</template>
<option value="all">{{ $ts.all }}</option>
<option value="available">{{ $ts.normal }}</option>
<option value="admin">{{ $ts.administrator }}</option>
<option value="moderator">{{ $ts.moderator }}</option>
<option value="silenced">{{ $ts.silence }}</option>
<option value="suspended">{{ $ts.suspend }}</option>
</MkSelect>
<MkSelect v-model="origin" style="flex: 1;">
<template #label>{{ $ts.instance }}</template>
<option value="combined">{{ $ts.all }}</option>
<option value="local">{{ $ts.local }}</option>
<option value="remote">{{ $ts.remote }}</option>
</MkSelect>
</div>
<div class="inputs">
<MkInput v-model="searchUsername" style="flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.users.reload()">
<template #prefix>@</template>
<template #label>{{ $ts.username }}</template>
</MkInput>
<MkInput v-model="searchHost" style="flex: 1;" type="text" spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:modelValue="$refs.users.reload()">
<template #prefix>@</template>
<template #label>{{ $ts.host }}</template>
</MkInput>
</div>
<MkPagination v-slot="{items}" ref="users" :pagination="pagination" class="users">
<button v-for="user in items" :key="user.id" class="user _panel _button _gap" @click="show(user)">
<MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/>
<div class="body">
<header>
<MkUserName class="name" :user="user"/>
<span class="acct">@{{ acct(user) }}</span>
<span v-if="user.isAdmin" class="staff"><img style="height: 20px;transform: translateY(4px);" src="https://s3.nca10.net/misskey/cb40a22b-cccf-490d-b224-bffa359a3462.png"/></span>
<span v-if="user.isModerator" class="staff"><img style="height: 20px;transform: translateY(4px);" src="https://s3.nca10.net/misskey/cb40a22b-cccf-490d-b224-bffa359a3462.png"/></span>
<span v-if="user.isSilenced" class="punished"><i class="fas fa-microphone-slash"></i></span>
<span v-if="user.isSuspended" class="punished"><i class="fas fa-snowflake"></i></span>
</header>
<div>
<span>{{ $ts.lastUsed }}: <MkTime v-if="user.updatedAt" :time="user.updatedAt" mode="detail"/></span>
</div>
<div>
<span>{{ $ts.registeredDate }}: <MkTime :time="user.createdAt" mode="detail"/></span>
</div>
</div>
</button>
</MkPagination>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent } from 'vue';
import MkButton from '@/components/ui/button.vue';
import MkInput from '@/components/form/input.vue';
import MkSelect from '@/components/form/select.vue';
import MkPagination from '@/components/ui/pagination.vue';
import { acct } from '@/filters/user';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { lookupUser } from '@/scripts/lookup-user';
export default defineComponent({
components: {
MkButton,
MkInput,
MkSelect,
MkPagination,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.users,
icon: 'fas fa-users',
bg: 'var(--bg)',
actions: [{
icon: 'fas fa-search',
text: this.$ts.search,
handler: this.searchUser
}, {
asFullButton: true,
icon: 'fas fa-plus',
text: this.$ts.addUser,
handler: this.addUser
}, {
asFullButton: true,
icon: 'fas fa-search',
text: this.$ts.lookup,
handler: this.lookupUser
}],
},
sort: '+createdAt',
state: 'all',
origin: 'local',
searchUsername: '',
searchHost: '',
pagination: {
endpoint: 'admin/show-users' as const,
limit: 10,
params: computed(() => ({
sort: this.sort,
state: this.state,
origin: this.origin,
username: this.searchUsername,
hostname: this.searchHost,
})),
offsetMode: true
},
}
},
methods: {
lookupUser,
searchUser() {
os.selectUser().then(user => {
this.show(user);
});
},
async addUser() {
const { canceled: canceled1, result: username } = await os.inputText({
title: this.$ts.username,
});
if (canceled1) return;
const { canceled: canceled2, result: password } = await os.inputText({
title: this.$ts.password,
type: 'password'
});
if (canceled2) return;
os.apiWithDialog('admin/accounts/create', {
username: username,
password: password,
}).then(res => {
this.$refs.users.reload();
});
},
show(user) {
os.pageWindow(`/user-info/${user.id}`);
},
acct
}
});
</script>
<style lang="scss" scoped>
.lknzcolw {
> .users {
margin: var(--margin);
> .inputs {
display: flex;
margin-bottom: 16px;
> * {
margin-right: 16px;
&:last-child {
margin-right: 0;
}
}
}
> .users {
margin-top: var(--margin);
> .user {
display: flex;
width: 100%;
box-sizing: border-box;
text-align: left;
align-items: center;
padding: 16px;
&:hover {
color: var(--accent);
}
> .avatar {
width: 60px;
height: 60px;
}
> .body {
margin-left: 0.3em;
padding: 0 8px;
flex: 1;
@media (max-width: 500px) {
font-size: 14px;
}
> header {
> .name {
font-weight: bold;
}
> .acct {
margin-left: 8px;
opacity: 0.7;
}
> .staff {
margin-left: 0.5em;
color: var(--badge);
height: 20px;
transform: translateY(4px);
}
> .punished {
margin-left: 0.5em;
color: #4dabf7;
}
}
}
}
}
}
}
</style>
<template>
<div class="lknzcolw">
<div class="users">
<div class="inputs">
<MkSelect v-model="sort" style="flex: 1;">
<template #label>{{ $ts.sort }}</template>
<option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option>
<option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option>
<option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option>
<option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option>
</MkSelect>
<MkSelect v-model="state" style="flex: 1;">
<template #label>{{ $ts.state }}</template>
<option value="all">{{ $ts.all }}</option>
<option value="available">{{ $ts.normal }}</option>
<option value="admin">{{ $ts.administrator }}</option>
<option value="moderator">{{ $ts.moderator }}</option>
<option value="silenced">{{ $ts.silence }}</option>
<option value="suspended">{{ $ts.suspend }}</option>
</MkSelect>
<MkSelect v-model="origin" style="flex: 1;">
<template #label>{{ $ts.instance }}</template>
<option value="combined">{{ $ts.all }}</option>
<option value="local">{{ $ts.local }}</option>
<option value="remote">{{ $ts.remote }}</option>
</MkSelect>
</div>
<div class="inputs">
<MkInput v-model="searchUsername" style="flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.users.reload()">
<template #prefix>@</template>
<template #label>{{ $ts.username }}</template>
</MkInput>
<MkInput v-model="searchHost" style="flex: 1;" type="text" spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:modelValue="$refs.users.reload()">
<template #prefix>@</template>
<template #label>{{ $ts.host }}</template>
</MkInput>
</div>
<MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination" class="users">
<button v-for="user in items" :key="user.id" class="user _panel _button _gap" @click="show(user)">
<MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/>
<div class="body">
<header>
<MkUserName class="name" :user="user"/>
<span class="acct">@{{ acct(user) }}</span>
<span v-if="user.isAdmin" class="staff"><img style="height: 20px;transform: translateY(4px);" src="https://s3.nca10.net/misskey/cb40a22b-cccf-490d-b224-bffa359a3462.png"/></span>
<span v-if="user.isModerator" class="staff"><img style="height: 20px;transform: translateY(4px);" src="https://s3.nca10.net/misskey/cb40a22b-cccf-490d-b224-bffa359a3462.png"/></span>
<span v-if="user.isSilenced" class="punished"><i class="fas fa-microphone-slash"></i></span>
<span v-if="user.isSuspended" class="punished"><i class="fas fa-snowflake"></i></span>
</header>
<div>
<span>{{ $ts.lastUsed }}: <MkTime v-if="user.updatedAt" :time="user.updatedAt" mode="detail"/></span>
</div>
<div>
<span>{{ $ts.registeredDate }}: <MkTime :time="user.createdAt" mode="detail"/></span>
</div>
</div>
</button>
</MkPagination>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import MkInput from '@/components/form/input.vue';
import MkSelect from '@/components/form/select.vue';
import MkPagination from '@/components/ui/pagination.vue';
import { acct } from '@/filters/user';
import * as os from '@/os';
import * as symbols from '@/symbols';
import { lookupUser } from '@/scripts/lookup-user';
import { i18n } from '@/i18n';
let paginationComponent = $ref<InstanceType<typeof MkPagination>>();
let sort = $ref('+createdAt');
let state = $ref('all');
let origin = $ref('local');
let searchUsername = $ref('');
let searchHost = $ref('');
const pagination = {
endpoint: 'admin/show-users' as const,
limit: 10,
params: computed(() => ({
sort: sort,
state: state,
origin: origin,
username: searchUsername,
hostname: searchHost,
})),
offsetMode: true
};
function searchUser() {
os.selectUser().then(user => {
show(user);
});
}
async function addUser() {
const { canceled: canceled1, result: username } = await os.inputText({
title: i18n.ts.username,
});
if (canceled1) return;
const { canceled: canceled2, result: password } = await os.inputText({
title: i18n.ts.password,
type: 'password'
});
if (canceled2) return;
os.apiWithDialog('admin/accounts/create', {
username: username,
password: password,
}).then(res => {
paginationComponent.reload();
});
}
function show(user) {
os.pageWindow(`/user-info/${user.id}`);
}
defineExpose({
[symbols.PAGE_INFO]: computed(() => ({
title: i18n.ts.users,
icon: 'fas fa-users',
bg: 'var(--bg)',
actions: [{
icon: 'fas fa-search',
text: i18n.ts.search,
handler: searchUser
}, {
asFullButton: true,
icon: 'fas fa-plus',
text: i18n.ts.addUser,
handler: addUser
}, {
asFullButton: true,
icon: 'fas fa-search',
text: i18n.ts.lookup,
handler: lookupUser
}],
})),
});
</script>
<style lang="scss" scoped>
.lknzcolw {
> .users {
margin: var(--margin);
> .inputs {
display: flex;
margin-bottom: 16px;
> * {
margin-right: 16px;
&:last-child {
margin-right: 0;
}
}
}
> .users {
margin-top: var(--margin);
> .user {
display: flex;
width: 100%;
box-sizing: border-box;
text-align: left;
align-items: center;
padding: 16px;
&:hover {
color: var(--accent);
}
> .avatar {
width: 60px;
height: 60px;
}
> .body {
margin-left: 0.3em;
padding: 0 8px;
flex: 1;
@media (max-width: 500px) {
font-size: 14px;
}
> header {
> .name {
font-weight: bold;
}
> .acct {
margin-left: 8px;
opacity: 0.7;
}
> .staff {
margin-left: 0.5em;
color: var(--badge);
height: 20px;
transform: translateY(4px);
}
> .punished {
margin-left: 0.5em;
color: #4dabf7;
}
}
}
}
}
}
}
</style>

View File

@ -19,7 +19,7 @@
<FormSection>
<template #label>{{ $ts.statistics }}</template>
<div ref="chart"></div>
<MkChart src="per-user-drive" :args="{ user: $i }" span="day" :limit="7 * 5" :bar="true" :stacked="true" :detailed="false" :aspect-ratio="6"/>
</FormSection>
<FormSection>
@ -45,8 +45,7 @@ import * as os from '@/os';
import bytes from '@/filters/bytes';
import * as symbols from '@/symbols';
import { defaultStore } from '@/store';
// TODO: render chart
import MkChart from '@/components/chart.vue';
export default defineComponent({
components: {
@ -55,6 +54,7 @@ export default defineComponent({
FormSection,
MkKeyValue,
FormSplit,
MkChart,
},
emits: ['info'],

View File

@ -12,6 +12,14 @@
</template>
</FormSelect>
<FormRadios v-model="overridedDeviceKind" class="_formBlock">
<template #label>{{ $ts.overridedDeviceKind }}</template>
<option :value="null">{{ $ts.auto }}</option>
<option value="smartphone"><i class="fas fa-mobile-alt"/> {{ $ts.smartphone }}</option>
<option value="tablet"><i class="fas fa-tablet-alt"/> {{ $ts.tablet }}</option>
<option value="desktop"><i class="fas fa-desktop"/> {{ $ts.desktop }}</option>
</FormRadios>
<FormSwitch v-model="showFixedPostForm" class="_formBlock">{{ $ts.showFixedPostForm }}</FormSwitch>
<FormSection>
@ -127,6 +135,7 @@ export default defineComponent({
},
computed: {
overridedDeviceKind: defaultStore.makeGetterSetter('overridedDeviceKind'),
serverDisconnectedBehavior: defaultStore.makeGetterSetter('serverDisconnectedBehavior'),
reduceAnimation: defaultStore.makeGetterSetter('animation', v => !v, v => !v),
useBlurEffectForModal: defaultStore.makeGetterSetter('useBlurEffectForModal'),
@ -193,6 +202,10 @@ export default defineComponent({
instanceTicker() {
this.reloadAsk();
},
overridedDeviceKind() {
this.reloadAsk();
},
},
methods: {

View File

@ -8,7 +8,7 @@
<template #caption>{{ $ts.makeReactionsPublicDescription }}</template>
</FormSwitch>
<FormSelect v-model="ffVisibility" class="_formBlock">
<FormSelect v-model="ffVisibility" class="_formBlock" @update:modelValue="save()">
<template #label>{{ $ts.ffVisibility }}</template>
<option value="public">{{ $ts._ffVisibility.public }}</option>
<option value="followers">{{ $ts._ffVisibility.followers }}</option>

View File

@ -38,7 +38,7 @@
</FormSlot>
<FormSwitch v-model="profile.isCat" class="_formBlock">{{ i18n.ts.flagAsCat }}<template #caption>{{ i18n.ts.flagAsCatDescription }}</template></FormSwitch>
<FormSwitch v-model="profile.showTimelineReplies" class="_formBlock">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }}</template></FormSwitch>
<FormSwitch v-model="profile.isBot" class="_formBlock">{{ i18n.ts.flagAsBot }}<template #caption>{{ i18n.ts.flagAsBotDescription }}</template></FormSwitch>
<FormSwitch v-model="profile.alwaysMarkNsfw" class="_formBlock">{{ i18n.ts.alwaysMarkSensitive }}</FormSwitch>
@ -68,6 +68,7 @@ const profile = reactive({
lang: $i.lang,
isBot: $i.isBot,
isCat: $i.isCat,
showTimelineReplies: $i.showTimelineReplies,
alwaysMarkNsfw: $i.alwaysMarkNsfw,
});
@ -97,6 +98,7 @@ function save() {
lang: profile.lang || null,
isBot: !!profile.isBot,
isCat: !!profile.isCat,
showTimelineReplies: !!profile.showTimelineReplies,
alwaysMarkNsfw: !!profile.alwaysMarkNsfw,
});
}

View File

@ -46,8 +46,10 @@ const keymap = {
const tlComponent = $ref<InstanceType<typeof XTimeline>>();
const rootEl = $ref<HTMLElement>();
let src = $ref<'home' | 'local' | 'social' | 'global'>(defaultStore.state.tl.src);
let queue = $ref(0);
const src = $computed(() => defaultStore.reactiveState.tl.value.src);
watch ($$(src), () => queue = 0);
function queueUpdated(q: number): void {
queue = q;
@ -60,7 +62,7 @@ function top(): void {
async function chooseList(ev: MouseEvent): Promise<void> {
const lists = await os.api('users/lists/list');
const items = lists.map(list => ({
type: 'link',
type: 'link' as const,
text: list.name,
to: `/timeline/list/${list.id}`,
}));
@ -70,7 +72,7 @@ async function chooseList(ev: MouseEvent): Promise<void> {
async function chooseAntenna(ev: MouseEvent): Promise<void> {
const antennas = await os.api('antennas/list');
const items = antennas.map(antenna => ({
type: 'link',
type: 'link' as const,
text: antenna.name,
indicate: antenna.hasUnreadNote,
to: `/timeline/antenna/${antenna.id}`,
@ -81,7 +83,7 @@ async function chooseAntenna(ev: MouseEvent): Promise<void> {
async function chooseChannel(ev: MouseEvent): Promise<void> {
const channels = await os.api('channels/followed');
const items = channels.map(channel => ({
type: 'link',
type: 'link' as const,
text: channel.name,
indicate: channel.hasUnreadNote,
to: `/channels/${channel.id}`,
@ -89,9 +91,10 @@ async function chooseChannel(ev: MouseEvent): Promise<void> {
os.popupMenu(items, ev.currentTarget ?? ev.target);
}
function saveSrc(): void {
function saveSrc(newSrc: 'home' | 'local' | 'social' | 'global'): void {
defaultStore.set('tl', {
src: src,
...defaultStore.state.tl,
src: newSrc,
});
}
@ -135,25 +138,25 @@ defineExpose({
title: i18n.ts._timelines.home,
icon: 'fas fa-home',
iconOnly: true,
onClick: () => { src = 'home'; saveSrc(); },
onClick: () => { saveSrc('home'); },
}, ...(isLocalTimelineAvailable ? [{
active: src === 'local',
title: i18n.ts._timelines.local,
icon: 'fas fa-comments',
iconOnly: true,
onClick: () => { src = 'local'; saveSrc(); },
onClick: () => { saveSrc('local'); },
}, {
active: src === 'social',
title: i18n.ts._timelines.social,
icon: 'fas fa-share-alt',
iconOnly: true,
onClick: () => { src = 'social'; saveSrc(); },
onClick: () => { saveSrc('social'); },
}] : []), ...(isGlobalTimelineAvailable ? [{
active: src === 'global',
title: i18n.ts._timelines.global,
icon: 'fas fa-globe',
iconOnly: true,
onClick: () => { src = 'global'; saveSrc(); },
onClick: () => { saveSrc('global'); },
}] : [])],
})),
});

View File

@ -3,7 +3,7 @@
<template #header><i class="fas fa-chart-bar" style="margin-right: 0.5em;"></i>{{ $ts.activity }}</template>
<div style="padding: 8px;">
<MkChart src="per-user-notes" :args="{ user, withoutAll: true }" span="day" :limit="limit" :stacked="true" :detailed="false" :aspect-ratio="6"/>
<MkChart src="per-user-notes" :args="{ user, withoutAll: true }" span="day" :limit="limit" :bar="true" :stacked="true" :detailed="false" :aspect-ratio="5"/>
</div>
</MkContainer>
</template>

View File

@ -0,0 +1,10 @@
import { defaultStore } from '@/store';
const ua = navigator.userAgent.toLowerCase();
const isTablet = /ipad/.test(ua) || (/mobile|iphone|android/.test(ua) && window.innerWidth > 700);
const isSmartphone = !isTablet && /mobile|iphone|android/.test(ua);
export const deviceKind = defaultStore.state.overridedDeviceKind ? defaultStore.state.overridedDeviceKind
: isSmartphone ? 'smartphone'
: isTablet ? 'tablet'
: 'desktop';

View File

@ -1,2 +0,0 @@
const ua = navigator.userAgent.toLowerCase();
export const isMobile = /mobile|iphone|ipad|android/.test(ua);

View File

@ -106,6 +106,10 @@ export const defaultStore = markRaw(new Storage('base', {
}
},
overridedDeviceKind: {
where: 'device',
default: null as null | 'smartphone' | 'tablet' | 'desktop',
},
serverDisconnectedBehavior: {
where: 'device',
default: 'quiet' as 'quiet' | 'reload' | 'dialog'

View File

@ -130,6 +130,11 @@
minimatch "^3.0.4"
strip-json-comments "^3.1.1"
"@fortawesome/fontawesome-free@6.0.0-beta3":
version "6.0.0-beta3"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-6.0.0-beta3.tgz#120e4a158a0de983924ce151bc35f27de46398b7"
integrity sha512-4SqOuhC8tSLeQvbW1nDmq6T7+8vdSgHy/w7PRwCFzMQCbKuYFIir/3UuWsV1QblX1lt7SGlSgwbaCv9XhRt8HA==
"@hapi/hoek@^9.0.0":
version "9.2.0"
resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.2.0.tgz#f3933a44e365864f4dad5db94158106d511e8131"
@ -266,11 +271,6 @@
resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0"
integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==
"@types/dateformat@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@types/dateformat/-/dateformat-3.0.1.tgz#98d747a2e5e9a56070c6bf14e27bff56204e34cc"
integrity sha512-KlPPdikagvL6ELjWsljbyDIPzNCeliYkqRpI+zea99vBBbCIA5JNshZAwQKTON139c87y9qvTFVgkFd14rtS4g==
"@types/escape-regexp@0.0.1":
version "0.0.1"
resolved "https://registry.yarnpkg.com/@types/escape-regexp/-/escape-regexp-0.0.1.tgz#f1a977ccdf2ef059e9862bd3af5e92cbbe723e0e"
@ -460,13 +460,6 @@
resolved "https://registry.yarnpkg.com/@types/random-seed/-/random-seed-0.3.3.tgz#7741f7b0a4513198a9396ce4ad25832f799a6727"
integrity sha512-kHsCbIRHNXJo6EN5W8EA5b4i1hdT6jaZke5crBPLUcLqaLdZ0QBq8QVMbafHzhjFF83Cl9qlee2dChD18d/kPg==
"@types/request-stats@3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/request-stats/-/request-stats-3.0.0.tgz#d3909a9f778b8ae0b42fb8c1ed20cb936ed95f99"
integrity sha512-POsDF7nETH8up49iBNvbZuO0pEk9F+TG0rXCkvjxCClcOS99xfF+mKmJteYlwKYpuRKkixzysKlL8rwN1hU2lw==
dependencies:
"@types/node" "*"
"@types/seedrandom@2.4.28":
version "2.4.28"
resolved "https://registry.yarnpkg.com/@types/seedrandom/-/seedrandom-2.4.28.tgz#9ce8fa048c1e8c85cb71d7fe4d704e000226036f"
@ -1591,6 +1584,11 @@ chartjs-adapter-date-fns@2.0.0:
resolved "https://registry.yarnpkg.com/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-2.0.0.tgz#5e53b2f660b993698f936f509c86dddf9ed44c6b"
integrity sha512-rmZINGLe+9IiiEB0kb57vH3UugAtYw33anRiw5kS2Tu87agpetDDoouquycWc9pRsKtQo5j+vLsYHyr8etAvFw==
chartjs-plugin-gradient@0.2.1:
version "0.2.1"
resolved "https://registry.yarnpkg.com/chartjs-plugin-gradient/-/chartjs-plugin-gradient-0.2.1.tgz#9d6d4f1a04a8d2ffca769adb068df4d0678b8f8f"
integrity sha512-hcNQ+B0LuiK9QXhbEc0tUtW3s0a8lOBUJViOCw2xHbnNCIp3Pul/tQHR1aIjMo3HiHu4nOb7NKqFd4NTUEsi4Q==
chartjs-plugin-zoom@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/chartjs-plugin-zoom/-/chartjs-plugin-zoom-1.2.0.tgz#dad0861b2d171bca1f6d11b3e3e917bc12b950ff"
@ -3224,13 +3222,6 @@ homedir-polyfill@^1.0.1:
dependencies:
parse-passwd "^1.0.0"
http-headers@^3.0.1:
version "3.0.2"
resolved "https://registry.yarnpkg.com/http-headers/-/http-headers-3.0.2.tgz#5147771292f0b39d6778d930a3a59a76fc7ef44d"
integrity sha512-87E1I+2Wg4dxxz4rcxElo3dxO/w1ZtgL1yA0Sb6vH3qU16vRKq1NjWQv9SCY3ly2OQROcoxHZOUpmelS+k6wOw==
dependencies:
next-line "^1.1.0"
http-signature@~1.3.6:
version "1.3.6"
resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.3.6.tgz#cb6fbfdf86d1c974f343be94e87f7fc128662cf9"
@ -4248,11 +4239,6 @@ netmask@^2.0.2:
resolved "https://registry.yarnpkg.com/netmask/-/netmask-2.0.2.tgz#8b01a07644065d536383835823bc52004ebac5e7"
integrity sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==
next-line@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/next-line/-/next-line-1.1.0.tgz#fcae57853052b6a9bae8208e40dd7d3c2d304603"
integrity sha1-/K5XhTBStqm66CCOQN19PC0wRgM=
next-tick@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.0.0.tgz#ca86d1fe8828169b0120208e3dc8424b9db8342c"
@ -5177,14 +5163,6 @@ request-progress@^3.0.0:
dependencies:
throttleit "^1.0.0"
request-stats@3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/request-stats/-/request-stats-3.0.0.tgz#769155dc8974d78d4a1cb87bbf14eaab985afe25"
integrity sha1-dpFV3Il0141KHLh7vxTqq5ha/iU=
dependencies:
http-headers "^3.0.1"
once "^1.4.0"
require-directory@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"