Merge branch 'misskey-dev:develop' into error-style

This commit is contained in:
Kainoa Kanter 2022-07-03 14:37:32 -07:00 committed by GitHub
commit 8a335caff5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
170 changed files with 2102 additions and 1299 deletions

View File

@ -22,21 +22,29 @@ You should also include the user name that made the change.
- Client: Improve control panel @syuilo
- Client: Show warning in control panel when there is an unresolved abuse report @syuilo
- Client: Add instance-cloud widget @syuilo
- Client: Add rss-marquee widget @syuilo
- Client: Add rss-ticker widget @syuilo
- Client: Removing entries from a clip @futchitwo
- Client: Poll highlights in explore page @syuilo
- Client: Improve deck UI @syuilo
- Client: Word mute also checks content warnings @Johann150
- ユーザーにモデレーションメモを残せる機能 @syuilo
- Make possible to delete an account by admin @syuilo
- Improve player detection in URL preview @mei23
- Add Badge Image to Push Notification #8012 @tamaina
- Server: Improve performance
- Server: Supports IPv6 on Redis transport. @mei23
IPv4/IPv6 is used by default. You can tune this behavior via `redis.family`.
- Server: Add possibility to log IP addresses of users @syuilo
### Bugfixes
- Server: Fix GenerateVideoThumbnail failed @mei23
- Server: Ensure temp directory cleanup @Johann150
- favicons of federated instances not showing @syuilo
- Admin: The checkbox for blocking an instance works again @Johann150
- Client: Prevent access to user pages when not logged in @pixeldesu @Johann150
- Client: Disable some hotkeys (e.g. for creating a post) for not logged in users @pixeldesu
- Client: Ask users that are not logged in to log in when trying to vote in a poll @Johann150
- Instance mutes also apply in antennas etc. @Johann150
## 12.111.1 (2022/06/13)

View File

@ -381,6 +381,7 @@ administrator: "管理者"
token: "トークン"
twoStepAuthentication: "二段階認証"
moderator: "モデレーター"
moderation: "モデレーション"
nUsersMentioned: "{n}人が投稿"
securityKey: "セキュリティキー"
securityKeyName: "キーの名前"
@ -854,6 +855,7 @@ noEmailServerWarning: "メールサーバーの設定がされていません。
thereIsUnresolvedAbuseReportWarning: "未対応の通報があります。"
recommended: "推奨"
check: "チェック"
requireAdminForView: "閲覧するには管理者アカウントでログインしている必要があります。"
isSystemAccount: "システムにより自動で作成・管理されているアカウントです。"
typeToConfirm: "この操作を行うには {x} と入力してください"
deleteAccount: "アカウント削除"
@ -861,6 +863,9 @@ document: "ドキュメント"
numberOfPageCache: "ページキャッシュ数"
numberOfPageCacheDescription: "多くすると利便性が向上しますが、負荷とメモリ使用量が増えます。"
logoutConfirm: "ログアウトしますか?"
lastActiveDate: "最終利用日時"
statusbar: "ステータスバー"
pleaseSelect: "選択してください"
_emailUnavailable:
used: "既に使用されています"
@ -1246,7 +1251,7 @@ _widgets:
trends: "トレンド"
clock: "時計"
rss: "RSSリーダー"
rssMarquee: "RSSリーダー(マーキー)"
rssTicker: "RSSティッカー"
activity: "アクティビティ"
photos: "フォト"
digitalClock: "デジタル時計"
@ -1716,8 +1721,6 @@ _notification:
_deck:
alwaysShowMainColumn: "常にメインカラムを表示"
columnAlign: "カラムの寄せ"
columnMargin: "カラム間のマージン"
columnHeaderHeight: "カラムのヘッダー幅"
addColumn: "カラムを追加"
swapLeft: "左に移動"
swapRight: "右に移動"
@ -1726,6 +1729,8 @@ _deck:
stackLeft: "左に重ねる"
popRight: "右に出す"
profile: "プロファイル"
introduction: "カラムを組み合わせて自分だけのインターフェイスを作りましょう!"
introduction2: "画面の右にある + を押して、いつでもカラムを追加できます。"
_columns:
main: "メイン"

View File

@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "12.112.0-beta.11",
"version": "12.112.0-beta.16",
"codename": "indigo",
"repository": {
"type": "git",

View File

@ -0,0 +1,17 @@
export class userIp1655918165614 {
name = 'userIp1655918165614'
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "user_ip" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "ip" character varying(128) NOT NULL, CONSTRAINT "PK_2c44ddfbf7c0464d028dcef325e" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_7f7f1c66f48e9a8e18a33bc515" ON "user_ip" ("userId") `);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_361b500e06721013c124b7b6c5" ON "user_ip" ("userId", "ip") `);
await queryRunner.query(`ALTER TABLE "user_ip" ADD CONSTRAINT "FK_7f7f1c66f48e9a8e18a33bc5150" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_ip" DROP CONSTRAINT "FK_7f7f1c66f48e9a8e18a33bc5150"`);
await queryRunner.query(`DROP INDEX "public"."IDX_361b500e06721013c124b7b6c5"`);
await queryRunner.query(`DROP INDEX "public"."IDX_7f7f1c66f48e9a8e18a33bc515"`);
await queryRunner.query(`DROP TABLE "user_ip"`);
}
}

View File

@ -0,0 +1,13 @@
export class fileIp1656122560740 {
name = 'fileIp1656122560740'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "drive_file" ADD "requestHeaders" jsonb DEFAULT '{}'`);
await queryRunner.query(`ALTER TABLE "drive_file" ADD "requestIp" character varying(128)`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "requestIp"`);
await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "requestHeaders"`);
}
}

View File

@ -0,0 +1,13 @@
export class ip21656328812281 {
name = 'ip21656328812281'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_ip" DROP CONSTRAINT "FK_7f7f1c66f48e9a8e18a33bc5150"`);
await queryRunner.query(`ALTER TABLE "meta" ADD "enableIpLogging" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableIpLogging"`);
await queryRunner.query(`ALTER TABLE "user_ip" ADD CONSTRAINT "FK_7f7f1c66f48e9a8e18a33bc5150" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
}

View File

@ -0,0 +1,11 @@
export class userModerationNote1656772790599 {
name = 'userModerationNote1656772790599'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" ADD "moderationNote" character varying(8192) NOT NULL DEFAULT ''`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "moderationNote"`);
}
}

View File

@ -92,6 +92,7 @@
"rename": "1.0.4",
"require-all": "3.0.0",
"rndstr": "1.0.0",
"rss-parser": "3.12.0",
"s-age": "1.1.2",
"sanitize-html": "2.7.0",
"semver": "7.3.7",

View File

@ -68,9 +68,10 @@ import { RegistryItem } from '@/models/entities/registry-item.js';
import { Ad } from '@/models/entities/ad.js';
import { PasswordResetRequest } from '@/models/entities/password-reset-request.js';
import { UserPending } from '@/models/entities/user-pending.js';
import { Webhook } from '@/models/entities/webhook.js';
import { UserIp } from '@/models/entities/user-ip.js';
import { entities as charts } from '@/services/chart/entities.js';
import { Webhook } from '@/models/entities/webhook.js';
import { envOption } from '../env.js';
import { dbLogger } from './logger.js';
import { redisClient } from './redis.js';
@ -173,6 +174,7 @@ export const entities = [
PasswordResetRequest,
UserPending,
Webhook,
UserIp,
...charts,
];

View File

@ -1,7 +1,7 @@
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { id } from '../id.js';
import { User } from './user.js';
import { DriveFolder } from './drive-folder.js';
import { id } from '../id.js';
@Entity()
@Index(['userId', 'folderId', 'id'])
@ -165,4 +165,15 @@ export class DriveFile {
comment: 'Whether the DriveFile is direct link to remote server.',
})
public isLink: boolean;
@Column('jsonb', {
default: {},
nullable: true,
})
public requestHeaders: Record<string, string> | null;
@Column('varchar', {
length: 128, nullable: true,
})
public requestIp: string | null;
}

View File

@ -1,6 +1,6 @@
import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
import { User } from './user.js';
import { id } from '../id.js';
import { User } from './user.js';
import { Clip } from './clip.js';
@Entity()
@ -427,4 +427,9 @@ export class Meta {
default: true,
})
public objectStorageS3ForcePathStyle: boolean;
@Column('boolean', {
default: false,
})
public enableIpLogging: boolean;
}

View File

@ -0,0 +1,24 @@
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { id } from '../id.js';
import { Note } from './note.js';
import { User } from './user.js';
@Entity()
@Index(['userId', 'ip'], { unique: true })
export class UserIp {
@PrimaryGeneratedColumn()
public id: string;
@Column('timestamp with time zone', {
})
public createdAt: Date;
@Index()
@Column(id())
public userId: User['id'];
@Column('varchar', {
length: 128,
})
public ip: string;
}

View File

@ -1,8 +1,8 @@
import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm';
import { ffVisibility, notificationTypes } from '@/types.js';
import { id } from '../id.js';
import { User } from './user.js';
import { Page } from './page.js';
import { ffVisibility, notificationTypes } from '@/types.js';
// TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも
// ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン
@ -117,6 +117,11 @@ export class UserProfile {
})
public password: string | null;
@Column('varchar', {
length: 8192, default: '',
})
public moderationNote: string | null;
// TODO: そのうち消す
@Column('jsonb', {
default: {},

View File

@ -65,6 +65,7 @@ import { PasswordResetRequest } from './entities/password-reset-request.js';
import { UserPending } from './entities/user-pending.js';
import { InstanceRepository } from './repositories/instance.js';
import { Webhook } from './entities/webhook.js';
import { UserIp } from './entities/user-ip.js';
export const Announcements = db.getRepository(Announcement);
export const AnnouncementReads = db.getRepository(AnnouncementRead);
@ -90,6 +91,7 @@ export const UserGroups = (UserGroupRepository);
export const UserGroupJoinings = db.getRepository(UserGroupJoining);
export const UserGroupInvitations = (UserGroupInvitationRepository);
export const UserNotePinings = db.getRepository(UserNotePining);
export const UserIps = db.getRepository(UserIp);
export const UsedUsernames = db.getRepository(UsedUsername);
export const Followings = (FollowingRepository);
export const FollowRequests = (FollowRequestRepository);

View File

@ -2,6 +2,9 @@ import httpSignature from '@peertube/http-signature';
import { v4 as uuid } from 'uuid';
import config from '@/config/index.js';
import { DriveFile } from '@/models/entities/drive-file.js';
import { IActivity } from '@/remote/activitypub/type.js';
import { Webhook, webhookEventTypes } from '@/models/entities/webhook.js';
import { envOption } from '../env.js';
import processDeliver from './processors/deliver.js';
@ -12,18 +15,15 @@ import processSystemQueue from './processors/system/index.js';
import processWebhookDeliver from './processors/webhook-deliver.js';
import { endedPollNotification } from './processors/ended-poll-notification.js';
import { queueLogger } from './logger.js';
import { DriveFile } from '@/models/entities/drive-file.js';
import { getJobInfo } from './get-job-info.js';
import { systemQueue, dbQueue, deliverQueue, inboxQueue, objectStorageQueue, endedPollNotificationQueue, webhookDeliverQueue } from './queues.js';
import { ThinUser } from './types.js';
import { IActivity } from '@/remote/activitypub/type.js';
import { Webhook, webhookEventTypes } from '@/models/entities/webhook.js';
function renderError(e: Error): any {
return {
stack: e?.stack,
message: e?.message,
name: e?.name,
stack: e.stack,
message: e.message,
name: e.name,
};
}
@ -314,6 +314,12 @@ export default function() {
removeOnComplete: true,
});
systemQueue.add('clean', {
}, {
repeat: { cron: '0 0 * * *' },
removeOnComplete: true,
});
systemQueue.add('checkExpiredMutings', {
}, {
repeat: { cron: '*/5 * * * *' },

View File

@ -0,0 +1,18 @@
import Bull from 'bull';
import { LessThan } from 'typeorm';
import { UserIps } from '@/models/index.js';
import { queueLogger } from '../../logger.js';
const logger = queueLogger.createSubLogger('clean');
export async function clean(job: Bull.Job<Record<string, unknown>>, done: any): Promise<void> {
logger.info('Cleaning...');
UserIps.delete({
createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))),
});
logger.succ('Cleaned.');
done();
}

View File

@ -3,12 +3,14 @@ import { tickCharts } from './tick-charts.js';
import { resyncCharts } from './resync-charts.js';
import { cleanCharts } from './clean-charts.js';
import { checkExpiredMutings } from './check-expired-mutings.js';
import { clean } from './clean.js';
const jobs = {
tickCharts,
resyncCharts,
cleanCharts,
checkExpiredMutings,
clean,
} as Record<string, Bull.ProcessCallbackFunction<Record<string, unknown>> | Bull.ProcessPromiseFunction<Record<string, unknown>>>;
export default function(dbQueue: Bull.Queue<Record<string, unknown>>) {

View File

@ -1,10 +1,19 @@
import Koa from 'koa';
import { User } from '@/models/entities/user.js';
import { UserIps } from '@/models/index.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
import { IEndpoint } from './endpoints.js';
import authenticate, { AuthenticationError } from './authenticate.js';
import call from './call.js';
import { ApiError } from './error.js';
const userIpHistories = new Map<User['id'], Set<string>>();
setInterval(() => {
userIpHistories.clear();
}, 1000 * 60 * 60);
export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise<void>((res) => {
const body = ctx.is('multipart/form-data')
? (ctx.request as any).body
@ -44,6 +53,31 @@ export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise<void>((res
}).catch((e: ApiError) => {
reply(e.httpStatusCode ? e.httpStatusCode : e.kind === 'client' ? 400 : 500, e);
});
// Log IP
if (user) {
fetchMeta().then(meta => {
if (!meta.enableIpLogging) return;
const ip = ctx.ip;
const ips = userIpHistories.get(user.id);
if (ips == null || !ips.has(ip)) {
if (ips == null) {
userIpHistories.set(user.id, new Set([ip]));
} else {
ips.add(ip);
}
try {
UserIps.insert({
createdAt: new Date(),
userId: user.id,
ip: ip,
});
} catch {
}
}
});
}
}).catch(e => {
if (e instanceof AuthenticationError) {
reply(403, new ApiError({

View File

@ -116,7 +116,7 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi
// API invoking
const before = performance.now();
return await ep.exec(data, user, token, ctx?.file).catch((e: Error) => {
return await ep.exec(data, user, token, ctx?.file, ctx?.ip, ctx?.headers).catch((e: Error) => {
if (e instanceof ApiError) {
throw e;
} else {

View File

@ -1,16 +1,16 @@
import * as fs from 'node:fs';
import Ajv from 'ajv';
import { CacheableLocalUser, ILocalUser } from '@/models/entities/user.js';
import { IEndpointMeta } from './endpoints.js';
import { ApiError } from './error.js';
import { Schema, SchemaType } from '@/misc/schema.js';
import { AccessToken } from '@/models/entities/access-token.js';
import { IEndpointMeta } from './endpoints.js';
import { ApiError } from './error.js';
export type Response = Record<string, any> | void;
// TODO: paramsの型をT['params']のスキーマ定義から推論する
type executor<T extends IEndpointMeta, Ps extends Schema> =
(params: SchemaType<Ps>, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, cleanup?: () => any) =>
(params: SchemaType<Ps>, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, cleanup?: () => any, ip?: string | null, headers?: Record<string, string> | null) =>
Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>;
const ajv = new Ajv({
@ -20,23 +20,27 @@ const ajv = new Ajv({
ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/);
export default function <T extends IEndpointMeta, Ps extends Schema>(meta: T, paramDef: Ps, cb: executor<T, Ps>)
: (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any) => Promise<any> {
: (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, ip?: string | null, headers?: Record<string, string> | null) => Promise<any> {
const validate = ajv.compile(paramDef);
return (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any) => {
function cleanup() {
fs.unlink(file.path, () => {});
}
return (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, ip?: string | null, headers?: Record<string, string> | null) => {
let cleanup: undefined | (() => void) = undefined;
if (meta.requireFile && file == null) return Promise.reject(new ApiError({
message: 'File required.',
code: 'FILE_REQUIRED',
id: '4267801e-70d1-416a-b011-4ee502885d8b',
}));
if (meta.requireFile) {
cleanup = () => {
fs.unlink(file.path, () => {});
};
if (file == null) return Promise.reject(new ApiError({
message: 'File required.',
code: 'FILE_REQUIRED',
id: '4267801e-70d1-416a-b011-4ee502885d8b',
}));
}
const valid = validate(params);
if (!valid) {
if (file) cleanup();
if (file) cleanup!();
const errors = validate.errors!;
const err = new ApiError({
@ -50,6 +54,6 @@ export default function <T extends IEndpointMeta, Ps extends Schema>(meta: T, pa
return Promise.reject(err);
}
return cb(params as SchemaType<Ps>, user, token, file, cleanup);
return cb(params as SchemaType<Ps>, user, token, file, cleanup, ip, headers);
};
}

View File

@ -35,6 +35,7 @@ import * as ep___admin_federation_removeAllFollowing from './endpoints/admin/fed
import * as ep___admin_federation_updateInstance from './endpoints/admin/federation/update-instance.js';
import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js';
import * as ep___admin_getTableStats from './endpoints/admin/get-table-stats.js';
import * as ep___admin_getUserIps from './endpoints/admin/get-user-ips.js';
import * as ep___admin_invite from './endpoints/admin/invite.js';
import * as ep___admin_moderators_add from './endpoints/admin/moderators/add.js';
import * as ep___admin_moderators_remove from './endpoints/admin/moderators/remove.js';
@ -60,6 +61,7 @@ import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js';
import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js';
import * as ep___admin_vacuum from './endpoints/admin/vacuum.js';
import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js';
import * as ep___admin_updateUserNote from './endpoints/admin/update-user-note.js';
import * as ep___announcements from './endpoints/announcements.js';
import * as ep___antennas_create from './endpoints/antennas/create.js';
import * as ep___antennas_delete from './endpoints/antennas/delete.js';
@ -311,6 +313,7 @@ import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by
import * as ep___users_search from './endpoints/users/search.js';
import * as ep___users_show from './endpoints/users/show.js';
import * as ep___users_stats from './endpoints/users/stats.js';
import * as ep___fetchRss from './endpoints/fetch-rss.js';
const eps = [
['admin/meta', ep___admin_meta],
@ -348,6 +351,7 @@ const eps = [
['admin/federation/update-instance', ep___admin_federation_updateInstance],
['admin/get-index-stats', ep___admin_getIndexStats],
['admin/get-table-stats', ep___admin_getTableStats],
['admin/get-user-ips', ep___admin_getUserIps],
['admin/invite', ep___admin_invite],
['admin/moderators/add', ep___admin_moderators_add],
['admin/moderators/remove', ep___admin_moderators_remove],
@ -373,6 +377,7 @@ const eps = [
['admin/update-meta', ep___admin_updateMeta],
['admin/vacuum', ep___admin_vacuum],
['admin/delete-account', ep___admin_deleteAccount],
['admin/update-user-note', ep___admin_updateUserNote],
['announcements', ep___announcements],
['antennas/create', ep___antennas_create],
['antennas/delete', ep___antennas_delete],
@ -624,6 +629,7 @@ const eps = [
['users/search', ep___users_search],
['users/show', ep___users_show],
['users/stats', ep___users_stats],
['fetch-rss', ep___fetchRss],
];
export interface IEndpointMeta {

View File

@ -1,6 +1,6 @@
import { DriveFiles } from '@/models/index.js';
import define from '../../../define.js';
import { ApiError } from '../../../error.js';
import { DriveFiles } from '@/models/index.js';
export const meta = {
tags: ['admin'],
@ -184,5 +184,10 @@ export default define(meta, paramDef, async (ps, me) => {
throw new ApiError(meta.errors.noSuchFile);
}
if (!me.isAdmin) {
delete file.requestIp;
delete file.requestHeaders;
}
return file;
});

View File

@ -0,0 +1,31 @@
import { UserIps } from '@/models/index.js';
import define from '../../define.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireAdmin: true,
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, me) => {
const ips = await UserIps.find({
where: { userId: ps.userId },
order: { createdAt: 'DESC' },
take: 30,
});
return ips.map(x => ({
ip: x.ip,
createdAt: x.createdAt.toISOString(),
}));
});

View File

@ -1,7 +1,7 @@
import config from '@/config/index.js';
import define from '../../define.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import define from '../../define.js';
export const meta = {
tags: ['meta'],
@ -304,6 +304,10 @@ export const meta = {
type: 'boolean',
optional: true, nullable: false,
},
enableIpLogging: {
type: 'boolean',
optional: true, nullable: false,
},
},
},
} as const;
@ -360,7 +364,6 @@ export default define(meta, paramDef, async (ps, me) => {
pinnedPages: instance.pinnedPages,
pinnedClipId: instance.pinnedClipId,
cacheRemoteFiles: instance.cacheRemoteFiles,
useStarForReactionFallback: instance.useStarForReactionFallback,
pinnedUsers: instance.pinnedUsers,
hiddenTags: instance.hiddenTags,
@ -397,5 +400,6 @@ export default define(meta, paramDef, async (ps, me) => {
objectStorageS3ForcePathStyle: instance.objectStorageS3ForcePathStyle,
deeplAuthKey: instance.deeplAuthKey,
deeplIsPro: instance.deeplIsPro,
enableIpLogging: instance.enableIpLogging,
};
});

View File

@ -25,7 +25,7 @@ export const paramDef = {
export default define(meta, paramDef, async (ps, me) => {
const [user, profile] = await Promise.all([
Users.findOneBy({ id: ps.userId }),
UserProfiles.findOneBy({ userId: ps.userId })
UserProfiles.findOneBy({ userId: ps.userId }),
]);
if (user == null || profile == null) {
@ -68,6 +68,8 @@ export default define(meta, paramDef, async (ps, me) => {
isModerator: user.isModerator,
isSilenced: user.isSilenced,
isSuspended: user.isSuspended,
lastActiveDate: user.lastActiveDate,
moderationNote: profile.moderationNote,
signins,
};
});

View File

@ -1,8 +1,8 @@
import define from '../../define.js';
import { Meta } from '@/models/entities/meta.js';
import { insertModerationLog } from '@/services/insert-moderation-log.js';
import { DB_MAX_NOTE_TEXT_LENGTH } from '@/misc/hard-limits.js';
import { db } from '@/db/postgre.js';
import define from '../../define.js';
export const meta = {
tags: ['admin'],
@ -96,6 +96,7 @@ export const paramDef = {
objectStorageUseProxy: { type: 'boolean' },
objectStorageSetPublicRead: { type: 'boolean' },
objectStorageS3ForcePathStyle: { type: 'boolean' },
enableIpLogging: { type: 'boolean' },
},
required: [],
} as const;
@ -396,6 +397,10 @@ export default define(meta, paramDef, async (ps, me) => {
set.deeplIsPro = ps.deeplIsPro;
}
if (ps.enableIpLogging !== undefined) {
set.enableIpLogging = ps.enableIpLogging;
}
await db.transaction(async transactionalEntityManager => {
const metas = await transactionalEntityManager.find(Meta, {
order: {

View File

@ -0,0 +1,31 @@
import { UserProfiles, Users } from '@/models/index.js';
import define from '../../define.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
text: { type: 'string' },
},
required: ['userId', 'text'],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, me) => {
const user = await Users.findOneBy({ id: ps.userId });
if (user == null) {
throw new Error('user not found');
}
await UserProfiles.update({ userId: user.id }, {
moderationNote: ps.text,
});
});

View File

@ -1,10 +1,11 @@
import ms from 'ms';
import { addFile } from '@/services/drive/add-file.js';
import { DriveFiles } from '@/models/index.js';
import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
import define from '../../../define.js';
import { apiLogger } from '../../../logger.js';
import { ApiError } from '../../../error.js';
import { DriveFiles } from '@/models/index.js';
import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js';
export const meta = {
tags: ['drive'],
@ -50,7 +51,7 @@ export const paramDef = {
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user, _, file, cleanup) => {
export default define(meta, paramDef, async (ps, user, _, file, cleanup, ip, headers) => {
// Get 'name' parameter
let name = ps.name || file.originalname;
if (name !== undefined && name !== null) {
@ -66,9 +67,21 @@ export default define(meta, paramDef, async (ps, user, _, file, cleanup) => {
name = null;
}
const meta = await fetchMeta();
try {
// Create file
const driveFile = await addFile({ user, path: file.path, name, comment: ps.comment, folderId: ps.folderId, force: ps.force, sensitive: ps.isSensitive });
const driveFile = await addFile({
user,
path: file.path,
name,
comment: ps.comment,
folderId: ps.folderId,
force: ps.force,
sensitive: ps.isSensitive,
requestIp: meta.enableIpLogging ? ip : null,
requestHeaders: meta.enableIpLogging ? headers : null,
});
return await DriveFiles.pack(driveFile, { self: true });
} catch (e) {
if (e instanceof Error || typeof e === 'string') {

View File

@ -1,9 +1,9 @@
import ms from 'ms';
import { uploadFromUrl } from '@/services/drive/upload-from-url.js';
import define from '../../../define.js';
import { DriveFiles } from '@/models/index.js';
import { publishMainStream } from '@/services/stream.js';
import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js';
import define from '../../../define.js';
export const meta = {
tags: ['drive'],
@ -34,8 +34,8 @@ export const paramDef = {
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps, user) => {
uploadFromUrl({ url: ps.url, user, folderId: ps.folderId, sensitive: ps.isSensitive, force: ps.force, comment: ps.comment }).then(file => {
export default define(meta, paramDef, async (ps, user, _1, _2, _3, ip, headers) => {
uploadFromUrl({ url: ps.url, user, folderId: ps.folderId, sensitive: ps.isSensitive, force: ps.force, comment: ps.comment, requestIp: ip, requestHeaders: headers }).then(file => {
DriveFiles.pack(file, { self: true }).then(packedFile => {
publishMainStream(user.id, 'urlUploadFinished', {
marker: ps.marker,

View File

@ -54,7 +54,7 @@ export default define(meta, paramDef, async (ps) => {
]);
const gotSubCount = topSubInstances.map(x => x.followersCount).reduce((a, b) => a + b, 0);
const gotPubCount = topSubInstances.map(x => x.followingCount).reduce((a, b) => a + b, 0);
const gotPubCount = topPubInstances.map(x => x.followingCount).reduce((a, b) => a + b, 0);
return await awaitAll({
topSubInstances: Instances.packMany(topSubInstances),

View File

@ -0,0 +1,39 @@
import Parser from 'rss-parser';
import { getResponse } from '@/misc/fetch.js';
import config from '@/config/index.js';
import define from '../define.js';
const rssParser = new Parser();
export const meta = {
tags: ['meta'],
requireCredential: false,
allowGet: true,
cacheSec: 60 * 3,
} as const;
export const paramDef = {
type: 'object',
properties: {
url: { type: 'string' },
},
required: ['url'],
} as const;
// eslint-disable-next-line import/no-default-export
export default define(meta, paramDef, async (ps) => {
const res = await getResponse({
url: ps.url,
method: 'GET',
headers: Object.assign({
'User-Agent': config.userAgent,
Accept: 'application/rss+xml, */*',
}),
timeout: 5000,
});
const text = await res.text();
return rssParser.parseString(text);
});

View File

@ -60,12 +60,21 @@ export default define(meta, paramDef, async (ps, user) => {
query.setParameters(mutingQuery.getParameters());
//#endregion
const polls = await query.take(ps.limit).skip(ps.offset).getMany();
const polls = await query
.orderBy('poll.noteId', 'DESC')
.take(ps.limit)
.skip(ps.offset)
.getMany();
if (polls.length === 0) return [];
const notes = await Notes.findBy({
id: In(polls.map(poll => poll.noteId)),
const notes = await Notes.find({
where: {
id: In(polls.map(poll => poll.noteId)),
},
order: {
createdAt: 'DESC',
},
});
return await Notes.packMany(notes, user, {

View File

@ -2,26 +2,26 @@ import * as fs from 'node:fs';
import { v4 as uuid } from 'uuid';
import S3 from 'aws-sdk/clients/s3.js';
import sharp from 'sharp';
import { IsNull } from 'typeorm';
import { publishMainStream, publishDriveStream } from '@/services/stream.js';
import { deleteFile } from './delete-file.js';
import { fetchMeta } from '@/misc/fetch-meta.js';
import { GenerateVideoThumbnail } from './generate-video-thumbnail.js';
import { driveLogger } from './logger.js';
import { IImage, convertSharpToJpeg, convertSharpToWebp, convertSharpToPng } from './image-processor.js';
import { contentDisposition } from '@/misc/content-disposition.js';
import { getFileInfo } from '@/misc/get-file-info.js';
import { DriveFiles, DriveFolders, Users, Instances, UserProfiles } from '@/models/index.js';
import { InternalStorage } from './internal-storage.js';
import { DriveFile } from '@/models/entities/drive-file.js';
import { IRemoteUser, User } from '@/models/entities/user.js';
import { driveChart, perUserDriveChart, instanceChart } from '@/services/chart/index.js';
import { genId } from '@/misc/gen-id.js';
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
import S3 from 'aws-sdk/clients/s3.js';
import { getS3 } from './s3.js';
import sharp from 'sharp';
import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
import { IsNull } from 'typeorm';
import { getS3 } from './s3.js';
import { InternalStorage } from './internal-storage.js';
import { IImage, convertSharpToJpeg, convertSharpToWebp, convertSharpToPng } from './image-processor.js';
import { driveLogger } from './logger.js';
import { GenerateVideoThumbnail } from './generate-video-thumbnail.js';
import { deleteFile } from './delete-file.js';
const logger = driveLogger.createSubLogger('register', 'yellow');
@ -171,7 +171,7 @@ export async function generateAlts(path: string, type: string, generateWeb: bool
}
if (!['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml'].includes(type)) {
logger.debug(`web image and thumbnail not created (not an required file)`);
logger.debug('web image and thumbnail not created (not an required file)');
return {
webpublic: null,
thumbnail: null,
@ -212,7 +212,7 @@ export async function generateAlts(path: string, type: string, generateWeb: bool
let webpublic: IImage | null = null;
if (generateWeb && !satisfyWebpublic) {
logger.info(`creating web image`);
logger.info('creating web image');
try {
if (['image/jpeg', 'image/webp'].includes(type)) {
@ -222,14 +222,14 @@ export async function generateAlts(path: string, type: string, generateWeb: bool
} else if (['image/svg+xml'].includes(type)) {
webpublic = await convertSharpToPng(img, 2048, 2048);
} else {
logger.debug(`web image not created (not an required image)`);
logger.debug('web image not created (not an required image)');
}
} catch (err) {
logger.warn(`web image not created (an error occured)`, err as Error);
logger.warn('web image not created (an error occured)', err as Error);
}
} else {
if (satisfyWebpublic) logger.info(`web image not created (original satisfies webpublic)`);
else logger.info(`web image not created (from remote)`);
if (satisfyWebpublic) logger.info('web image not created (original satisfies webpublic)');
else logger.info('web image not created (from remote)');
}
// #endregion webpublic
@ -240,10 +240,10 @@ export async function generateAlts(path: string, type: string, generateWeb: bool
if (['image/jpeg', 'image/webp', 'image/png', 'image/svg+xml'].includes(type)) {
thumbnail = await convertSharpToWebp(img, 498, 280);
} else {
logger.debug(`thumbnail not created (not an required file)`);
logger.debug('thumbnail not created (not an required file)');
}
} catch (err) {
logger.warn(`thumbnail not created (an error occured)`, err as Error);
logger.warn('thumbnail not created (an error occured)', err as Error);
}
// #endregion thumbnail
@ -276,7 +276,7 @@ async function upload(key: string, stream: fs.ReadStream | Buffer, type: string,
const s3 = getS3(meta);
const upload = s3.upload(params, {
partSize: s3.endpoint?.hostname === 'storage.googleapis.com' ? 500 * 1024 * 1024 : 8 * 1024 * 1024,
partSize: s3.endpoint.hostname === 'storage.googleapis.com' ? 500 * 1024 * 1024 : 8 * 1024 * 1024,
});
const result = await upload.promise();
@ -326,6 +326,9 @@ type AddFileArgs = {
uri?: string | null;
/** Mark file as sensitive */
sensitive?: boolean | null;
requestIp?: string | null;
requestHeaders?: Record<string, string> | null;
};
/**
@ -342,7 +345,9 @@ export async function addFile({
isLink = false,
url = null,
uri = null,
sensitive = null
sensitive = null,
requestIp = null,
requestHeaders = null,
}: AddFileArgs): Promise<DriveFile> {
const info = await getFileInfo(path);
logger.info(`${JSON.stringify(info)}`);
@ -427,11 +432,13 @@ export async function addFile({
file.properties = properties;
file.blurhash = info.blurhash || null;
file.isLink = isLink;
file.requestIp = requestIp;
file.requestHeaders = requestHeaders;
file.isSensitive = user
? Users.isLocalUser(user) && profile!.alwaysMarkNsfw ? true :
(sensitive !== null && sensitive !== undefined)
? sensitive
: false
(sensitive !== null && sensitive !== undefined)
? sensitive
: false
: false;
if (url !== null) {

View File

@ -1,12 +1,12 @@
import { URL } from 'node:url';
import { addFile } from './add-file.js';
import { User } from '@/models/entities/user.js';
import { driveLogger } from './logger.js';
import { createTemp } from '@/misc/create-temp.js';
import { downloadUrl } from '@/misc/download-url.js';
import { DriveFolder } from '@/models/entities/drive-folder.js';
import { DriveFile } from '@/models/entities/drive-file.js';
import { DriveFiles } from '@/models/index.js';
import { driveLogger } from './logger.js';
import { addFile } from './add-file.js';
const logger = driveLogger.createSubLogger('downloader');
@ -19,6 +19,8 @@ type Args = {
force?: boolean;
isLink?: boolean;
comment?: string | null;
requestIp?: string | null;
requestHeaders?: Record<string, string> | null;
};
export async function uploadFromUrl({
@ -30,6 +32,8 @@ export async function uploadFromUrl({
force = false,
isLink = false,
comment = null,
requestIp = null,
requestHeaders = null,
}: Args): Promise<DriveFile> {
let name = new URL(url).pathname.split('/').pop() || null;
if (name == null || !DriveFiles.validateFileName(name)) {
@ -49,7 +53,7 @@ export async function uploadFromUrl({
// write content at URL to temp file
await downloadUrl(url, path);
const driveFile = await addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive });
const driveFile = await addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders });
logger.succ(`Got: ${driveFile.id}`);
return driveFile!;
} catch (e) {

File diff suppressed because it is too large Load Diff

View File

@ -186,7 +186,7 @@ export function connectStream(user: any, channel: string, listener: (message: Re
});
}
export const waitFire = async (user: any, channel: string, trgr: () => any, cond: (msg: Record<string, any>) => boolean) => {
export const waitFire = async (user: any, channel: string, trgr: () => any, cond: (msg: Record<string, any>) => boolean, params?: any) => {
return new Promise<boolean>(async (res, rej) => {
let timer: NodeJS.Timeout;
@ -198,7 +198,7 @@ export const waitFire = async (user: any, channel: string, trgr: () => any, cond
if (timer) clearTimeout(timer);
res(true);
}
});
}, params);
} catch (e) {
rej(e);
}
@ -208,7 +208,7 @@ export const waitFire = async (user: any, channel: string, trgr: () => any, cond
timer = setTimeout(() => {
ws.close();
res(false);
}, 5000);
}, 3000);
try {
await trgr();

View File

@ -2485,6 +2485,11 @@ entities@^2.0.0:
resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.0.tgz#68d6084cab1b079767540d80e56a39b423e4abf4"
integrity sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw==
entities@^2.0.3:
version "2.2.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55"
integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==
entities@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-4.3.0.tgz#62915f08d67353bb4eb67e3d62641a4059aec656"
@ -5985,6 +5990,14 @@ rndstr@1.0.0:
rangestr "0.0.1"
seedrandom "2.4.2"
rss-parser@3.12.0:
version "3.12.0"
resolved "https://registry.yarnpkg.com/rss-parser/-/rss-parser-3.12.0.tgz#b8888699ea46304a74363fbd8144671b2997984c"
integrity sha512-aqD3E8iavcCdkhVxNDIdg1nkBI17jgqF+9OqPS1orwNaOgySdpvq6B+DoONLhzjzwV8mWg37sb60e4bmLK117A==
dependencies:
entities "^2.0.3"
xml2js "^0.4.19"
run-parallel@^1.1.9:
version "1.1.9"
resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.1.9.tgz#c9dd3a7cf9f4b2c4b6244e173a6ed866e61dd679"
@ -7174,7 +7187,7 @@ xml2js@0.4.19:
sax ">=0.6.0"
xmlbuilder "~9.0.1"
xml2js@^0.4.23:
xml2js@^0.4.19, xml2js@^0.4.23:
version "0.4.23"
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66"
integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==

View File

@ -22,9 +22,8 @@ module.exports = {
},
],
// window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため
// data の禁止理由: 抽象的すぎるため
// e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため
'id-denylist': ['error', 'window', 'data', 'e'],
'id-denylist': ['error', 'window', 'e'],
'no-shadow': ['warn'],
'vue/attributes-order': ['error', {
'alphabetical': false,

View File

@ -76,7 +76,6 @@
"vanilla-tilt": "1.7.2",
"vite": "3.0.0-beta.5",
"vue": "3.2.37",
"vue-marquee-text-component": "2.0.1",
"vue-prism-editor": "2.0.0-alpha.2",
"vuedraggable": "4.0.1",
"websocket": "1.0.34",

View File

@ -17,6 +17,7 @@ const accountData = localStorage.getItem('account');
export const $i = accountData ? reactive(JSON.parse(accountData) as Account) : null;
export const iAmModerator = $i != null && ($i.isAdmin || $i.isModerator);
export const iAmAdmin = $i != null && $i.isAdmin;
export async function signout() {
waiting();

View File

@ -59,7 +59,7 @@ const isThumbnailAvailable = computed(() => {
display: flex;
background: var(--panel);
border-radius: 8px;
overflow: clip;
overflow: hidden; overflow: clip;
> .icon-sub {
position: absolute;

View File

@ -41,7 +41,7 @@
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
<option v-for="item in form[item].options" :key="item.value" :value="item.value">{{ item.label }}</option>
</FormRadios>
<FormRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].mim" :max="form[item].max" :step="form[item].step" :text-converter="form[item].textConverter" class="_formBlock">
<FormRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :text-converter="form[item].textConverter" class="_formBlock">
<template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template>
<template v-if="form[item].description" #caption>{{ form[item].description }}</template>
</FormRange>

View File

@ -6,7 +6,7 @@
<div class="track">
<div class="highlight" :style="{ width: (steppedRawValue * 100) + '%' }"></div>
</div>
<div v-if="steps" class="ticks">
<div v-if="steps && showTicks" class="ticks">
<div v-for="i in (steps + 1)" class="tick" :style="{ left: (((i - 1) / steps) * 100) + '%' }"></div>
</div>
<div ref="thumbEl" v-tooltip="textConverter(finalValue)" class="thumb" :style="{ left: thumbPosition + 'px' }" @mousedown="onMousedown" @touchstart="onMousedown"></div>
@ -27,6 +27,7 @@ const props = withDefaults(defineProps<{
max: number;
step?: number;
textConverter?: (value: number) => string,
showTicks?: boolean;
}>(), {
step: 1,
textConverter: (v) => v.toString(),
@ -188,7 +189,7 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => {
height: 3px;
background: rgba(0, 0, 0, 0.1);
border-radius: 999px;
overflow: clip;
overflow: hidden; overflow: clip;
> .highlight {
position: absolute;

View File

@ -172,11 +172,8 @@ onUnmounted(() => {
<style lang="scss" scoped>
.fdidabkb {
--height: 60px;
--height: 55px;
display: flex;
position: sticky;
top: var(--stickyTop, 0);
z-index: 1000;
width: 100%;
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));

View File

@ -1,46 +1,38 @@
<template>
<div ref="rootEl">
<slot name="header"></slot>
<div ref="headerEl">
<slot name="header"></slot>
</div>
<div ref="bodyEl" :data-sticky-container-header-height="headerHeight">
<slot></slot>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted } from 'vue';
<script lang="ts">
//
//const CURRENT_STICKY_TOP = Symbol('CURRENT_STICKY_TOP');
const CURRENT_STICKY_TOP = 'CURRENT_STICKY_TOP';
</script>
const props = withDefaults(defineProps<{
autoSticky?: boolean;
}>(), {
autoSticky: false,
});
<script lang="ts" setup>
import { onMounted, onUnmounted, provide, inject, Ref, ref, watch } from 'vue';
const rootEl = $ref<HTMLElement>();
const headerEl = $ref<HTMLElement>();
const bodyEl = $ref<HTMLElement>();
let headerHeight = $ref<string | undefined>();
let childStickyTop = $ref(0);
const parentStickyTop = inject<Ref<number>>(CURRENT_STICKY_TOP, ref(0));
provide(CURRENT_STICKY_TOP, $$(childStickyTop));
const calc = () => {
const currentStickyTop = getComputedStyle(rootEl).getPropertyValue('--stickyTop') || '0px';
const header = rootEl.children[0] as HTMLElement;
if (header === bodyEl) {
bodyEl.style.setProperty('--stickyTop', currentStickyTop);
} else {
bodyEl.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`);
headerHeight = header.offsetHeight.toString();
if (props.autoSticky) {
header.style.setProperty('--stickyTop', currentStickyTop);
header.style.position = 'sticky';
header.style.top = 'var(--stickyTop)';
header.style.zIndex = '1';
}
}
childStickyTop = parentStickyTop.value + headerEl.offsetHeight;
headerHeight = headerEl.offsetHeight.toString();
};
const observer = new MutationObserver(() => {
const observer = new ResizeObserver(() => {
window.setTimeout(() => {
calc();
}, 100);
@ -49,11 +41,19 @@ const observer = new MutationObserver(() => {
onMounted(() => {
calc();
observer.observe(rootEl, {
attributes: false,
childList: true,
subtree: false,
watch(parentStickyTop, calc);
watch($$(childStickyTop), () => {
bodyEl.style.setProperty('--stickyTop', `${childStickyTop}px`);
}, {
immediate: true,
});
headerEl.style.position = 'sticky';
headerEl.style.top = 'var(--stickyTop, 0)';
headerEl.style.zIndex = '1000';
observer.observe(headerEl);
});
onUnmounted(() => {

View File

@ -0,0 +1,99 @@
<script lang="ts">
import { h, onMounted, onUnmounted, ref, watch } from 'vue';
export default {
name: 'MarqueeText',
props: {
duration: {
type: Number,
default: 15,
},
repeat: {
type: Number,
default: 2,
},
paused: {
type: Boolean,
default: false,
},
reverse: {
type: Boolean,
default: false,
},
},
setup(props) {
const contentEl = ref();
function calc() {
const eachLength = contentEl.value.offsetWidth / props.repeat;
const factor = 3000;
const duration = props.duration / ((1 / eachLength) * factor);
contentEl.value.style.animationDuration = `${duration}s`;
}
watch(() => props.duration, calc);
onMounted(() => {
calc();
});
onUnmounted(() => {
});
return {
contentEl,
};
},
render({
$slots, $style, $props: {
duration, repeat, paused, reverse,
},
}) {
return h('div', { class: [$style.wrap] }, [
h('span', {
ref: 'contentEl',
class: [
paused
? $style.paused
: undefined,
$style.content,
],
}, Array(repeat).fill(
h('span', {
class: $style.text,
style: {
animationDirection: reverse
? 'reverse'
: undefined,
},
}, $slots.default()),
)),
]);
},
};
</script>
<style lang="scss" module>
.wrap {
overflow: hidden; overflow: clip;
}
.content {
display: inline-block;
white-space: nowrap;
}
.text {
display: inline-block;
animation-name: marquee;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-duration: inherit;
}
.paused .text {
animation-play-state: paused;
}
@keyframes marquee {
0% { transform:translateX(0); }
100% { transform:translateX(-100%); }
}
</style>

View File

@ -27,7 +27,7 @@ const props = defineProps<{
display: flex;
margin: 0;
padding: 0;
overflow: clip;
overflow: hidden; overflow: clip;
font-size: 0.95em;
&.min-width_350px {

View File

@ -36,7 +36,7 @@ const showContent = $ref(false);
display: flex;
margin: 0;
padding: 0;
overflow: clip;
overflow: hidden; overflow: clip;
font-size: 0.95em;
&.min-width_350px {

View File

@ -297,7 +297,7 @@ function readPromo() {
position: relative;
transition: box-shadow 0.1s ease;
font-size: 1.05em;
overflow: clip;
overflow: hidden; overflow: clip;
contain: content;
//

View File

@ -48,7 +48,10 @@ const router = new Router(routes, props.initialPath);
let pageMetadata = $ref<null | ComputedRef<PageMetadata>>();
let windowEl = $ref<InstanceType<typeof XWindow>>();
const history = $ref<string[]>([props.initialPath]);
const history = $ref<{ path: string; key: any; }[]>([{
path: router.getCurrentPath(),
key: router.getCurrentKey(),
}]);
const buttonsLeft = $computed(() => {
const buttons = [];
@ -72,7 +75,7 @@ const buttonsRight = $computed(() => {
});
router.addListener('push', ctx => {
history.push(router.getCurrentPath());
history.push({ path: ctx.path, key: ctx.key });
});
provide('router', router);
@ -111,7 +114,7 @@ function menu(ev) {
function back() {
history.pop();
router.change(history[history.length - 1]);
router.change(history[history.length - 1].path, history[history.length - 1].key);
}
function close() {
@ -136,5 +139,6 @@ defineExpose({
<style lang="scss" scoped>
.yrolvcoq {
min-height: 100%;
background: var(--bg);
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<div ref="rootEl" class="root">
<div ref="rootEl" class="meijqfqm">
<canvas :id="idForCanvas" ref="canvasEl" class="canvas" :width="width" height="300" @contextmenu.prevent="() => {}"></canvas>
<div :id="idForTags" ref="tagsEl" class="tags">
<ul>
@ -71,9 +71,9 @@ defineExpose({
</script>
<style lang="scss" scoped>
.root {
.meijqfqm {
position: relative;
overflow: clip;
overflow: hidden; overflow: clip;
display: grid;
place-items: center;

View File

@ -54,7 +54,7 @@ onMounted(() => {
width: min-content;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
border-radius: 8px;
overflow: clip;
overflow: hidden; overflow: clip;
text-align: center;
pointer-events: none;

View File

@ -148,7 +148,7 @@ export default defineComponent({
text-decoration: none;
background: var(--buttonBg);
border-radius: 5px;
overflow: clip;
overflow: hidden; overflow: clip;
box-sizing: border-box;
transition: background 0.1s ease;

View File

@ -10,7 +10,8 @@
</button>
</div>
</header>
<transition :name="$store.state.animation ? 'container-toggle' : ''"
<transition
:name="$store.state.animation ? 'container-toggle' : ''"
@enter="enter"
@after-enter="afterEnter"
@leave="leave"
@ -34,37 +35,37 @@ export default defineComponent({
showHeader: {
type: Boolean,
required: false,
default: true
default: true,
},
thin: {
type: Boolean,
required: false,
default: false
default: false,
},
naked: {
type: Boolean,
required: false,
default: false
default: false,
},
foldable: {
type: Boolean,
required: false,
default: false
default: false,
},
expanded: {
type: Boolean,
required: false,
default: true
default: true,
},
scrollable: {
type: Boolean,
required: false,
default: false
default: false,
},
maxHeight: {
type: Number,
required: false,
default: null
default: null,
},
},
data() {
@ -79,12 +80,12 @@ export default defineComponent({
const headerHeight = this.showHeader ? this.$refs.header.offsetHeight : 0;
this.$el.style.minHeight = `${headerHeight}px`;
if (showBody) {
this.$el.style.flexBasis = `auto`;
this.$el.style.flexBasis = 'auto';
} else {
this.$el.style.flexBasis = `${headerHeight}px`;
}
}, {
immediate: true
immediate: true,
});
this.$el.style.setProperty('--maxHeight', this.maxHeight + 'px');
@ -124,7 +125,7 @@ export default defineComponent({
afterLeave(el) {
el.style.height = null;
},
}
},
});
</script>
@ -142,7 +143,7 @@ export default defineComponent({
.ukygtjoj {
position: relative;
overflow: clip;
overflow: hidden; overflow: clip;
&.naked {
background: transparent !important;

View File

@ -136,11 +136,11 @@ function focusDown() {
> .item {
display: block;
position: relative;
padding: 8px 18px;
padding: 6px 18px;
width: 100%;
box-sizing: border-box;
white-space: nowrap;
font-size: 0.9em;
font-size: 0.85em;
line-height: 20px;
text-align: left;
overflow: hidden;

View File

@ -389,7 +389,7 @@ defineExpose({
left: 0;
width: 100%;
height: 100%;
overflow: clip;
overflow: hidden; overflow: clip;
> .content {
position: fixed;

View File

@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<div style="overflow: clip;">
<div style="overflow: hidden; overflow: clip;">
<MkSpacer :content-max="600" :margin-min="20">
<div class="_formRoot znqjceqz">
<div id="debug"></div>
@ -204,7 +204,6 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.aboutMisskey,
icon: null,
bg: 'var(--bg)',
});
</script>

View File

@ -131,7 +131,6 @@ const headerTabs = $computed(() => [{
definePageMetadata(computed(() => ({
title: i18n.ts.instanceInfo,
icon: 'fas fa-info-circle',
bg: 'var(--bg)',
})));
</script>
@ -139,7 +138,7 @@ definePageMetadata(computed(() => ({
.fwhjspax {
text-align: center;
border-radius: 10px;
overflow: clip;
overflow: hidden; overflow: clip;
background-size: cover;
background-position: center center;

View File

@ -1,7 +1,7 @@
<template>
<MkStickyContainer>
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer v-if="file" :content-max="500" :margin-min="16" :margin-max="32">
<MkSpacer v-if="file" :content-max="600" :margin-min="16" :margin-max="32">
<div v-if="tab === 'overview'" class="cxqhhsmd _formRoot">
<a class="_formBlock thumbnail" :href="file.url" target="_blank">
<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
@ -39,6 +39,20 @@
<MkButton danger @click="del"><i class="fas fa-trash-alt"></i> {{ i18n.ts.delete }}</MkButton>
</div>
</div>
<div v-else-if="tab === 'ip' && info" class="_formRoot">
<MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo>
<MkKeyValue v-if="info.requestIp" class="_formBlock _monospace" :copy="info.requestIp" oneline>
<template #key>IP</template>
<template #value>{{ info.requestIp }}</template>
</MkKeyValue>
<FormSection v-if="info.requestHeaders">
<template #label>Headers</template>
<MkKeyValue v-for="(v, k) in info.requestHeaders" :key="k" class="_formBlock _monospace">
<template #key>{{ k }}</template>
<template #value>{{ v }}</template>
</MkKeyValue>
</FormSection>
</div>
<div v-else-if="tab === 'raw'" class="_formRoot">
<MkObjectView v-if="info" tall :value="info">
</MkObjectView>
@ -54,13 +68,15 @@ import MkSwitch from '@/components/form/switch.vue';
import MkObjectView from '@/components/object-view.vue';
import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue';
import MkKeyValue from '@/components/key-value.vue';
import FormLink from '@/components/form/link.vue';
import FormSection from '@/components/form/section.vue';
import MkUserCardMini from '@/components/user-card-mini.vue';
import MkInfo from '@/components/ui/info.vue';
import bytes from '@/filters/bytes';
import * as os from '@/os';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { acct } from '@/filters/user';
import { iAmAdmin, iAmModerator } from '@/account';
let tab = $ref('overview');
let file: any = $ref(null);
@ -108,7 +124,11 @@ const headerTabs = $computed(() => [{
key: 'overview',
title: i18n.ts.overview,
icon: 'fas fa-info-circle',
}, {
}, iAmModerator ? {
key: 'ip',
title: 'IP',
icon: 'fas fa-bars-staggered',
} : null, {
key: 'raw',
title: 'Raw data',
icon: 'fas fa-code',
@ -117,7 +137,6 @@ const headerTabs = $computed(() => [{
definePageMetadata(computed(() => ({
title: file ? i18n.ts.file + ': ' + file.name : i18n.ts.file,
icon: 'fas fa-file',
bg: 'var(--bg)',
})));
</script>

View File

@ -152,9 +152,6 @@ onUnmounted(() => {
.fdidabkc {
--height: 60px;
display: flex;
position: sticky;
top: var(--stickyTop, 0);
z-index: 1000;
width: 100%;
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));

View File

@ -87,7 +87,6 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.abuseReports,
icon: 'fas fa-exclamation-circle',
bg: 'var(--bg)',
});
</script>

View File

@ -116,7 +116,6 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.ads,
icon: 'fas fa-audio-description',
bg: 'var(--bg)',
});
</script>

View File

@ -102,7 +102,6 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.announcements,
icon: 'fas fa-broadcast-tower',
bg: 'var(--bg)',
});
</script>

View File

@ -29,6 +29,5 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.database,
icon: 'fas fa-database',
bg: 'var(--bg)',
});
</script>

View File

@ -122,6 +122,5 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.emailServer,
icon: 'fas fa-envelope',
bg: 'var(--bg)',
});
</script>

View File

@ -292,7 +292,6 @@ const headerTabs = $computed(() => [{
definePageMetadata(computed(() => ({
title: i18n.ts.customEmojis,
icon: 'fas fa-laugh',
bg: 'var(--bg)',
})));
</script>

View File

@ -110,7 +110,6 @@ const headerTabs = $computed(() => []);
definePageMetadata(computed(() => ({
title: i18n.ts.files,
icon: 'fas fa-cloud',
bg: 'var(--bg)',
})));
</script>

View File

@ -41,7 +41,6 @@ const router = useRouter();
const indexInfo = {
title: i18n.ts.controlPanel,
icon: 'fas fa-cog',
bg: 'var(--bg)',
hideHeader: true,
};

View File

@ -47,6 +47,5 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.instanceBlocking,
icon: 'fas fa-ban',
bg: 'var(--bg)',
});
</script>

View File

@ -53,6 +53,5 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.integration,
icon: 'fas fa-share-alt',
bg: 'var(--bg)',
});
</script>

View File

@ -144,6 +144,5 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.objectStorage,
icon: 'fas fa-cloud',
bg: 'var(--bg)',
});
</script>

View File

@ -40,6 +40,5 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.other,
icon: 'fas fa-cogs',
bg: 'var(--bg)',
});
</script>

View File

@ -468,7 +468,6 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.dashboard,
icon: 'fas fa-tachometer-alt',
bg: 'var(--bg)',
});
</script>
@ -562,7 +561,7 @@ definePageMetadata({
> .body {
background: var(--panel);
border-radius: var(--radius);
overflow: clip;
overflow: hidden; overflow: clip;
}
}
@ -621,7 +620,7 @@ definePageMetadata({
> .body {
background: var(--panel);
border-radius: var(--radius);
overflow: clip;
overflow: hidden; overflow: clip;
}
}
}

View File

@ -58,6 +58,5 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.proxyAccount,
icon: 'fas fa-ghost',
bg: 'var(--bg)',
});
</script>

View File

@ -52,6 +52,5 @@ const headerTabs = $computed(() => [{
definePageMetadata({
title: i18n.ts.jobQueue,
icon: 'fas fa-clipboard-list',
bg: 'var(--bg)',
});
</script>

View File

@ -78,7 +78,6 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.relays,
icon: 'fas fa-globe',
bg: 'var(--bg)',
});
</script>

View File

@ -14,6 +14,18 @@
<XBotProtection/>
</FormFolder>
<FormFolder class="_formBlock">
<template #label>Log IP address</template>
<template v-if="enableIpLogging" #suffix>Enabled</template>
<template v-else #suffix>Disabled</template>
<div class="_formRoot">
<FormSwitch v-model="enableIpLogging" class="_formBlock" @update:modelValue="save">
<template #label>Enable</template>
</FormSwitch>
</div>
</FormFolder>
<FormFolder class="_formBlock">
<template #label>Summaly Proxy</template>
@ -51,17 +63,20 @@ import { definePageMetadata } from '@/scripts/page-metadata';
let summalyProxy: string = $ref('');
let enableHcaptcha: boolean = $ref(false);
let enableRecaptcha: boolean = $ref(false);
let enableIpLogging: boolean = $ref(false);
async function init() {
const meta = await os.api('admin/meta');
summalyProxy = meta.summalyProxy;
enableHcaptcha = meta.enableHcaptcha;
enableRecaptcha = meta.enableRecaptcha;
enableIpLogging = meta.enableIpLogging;
}
function save() {
os.apiWithDialog('admin/update-meta', {
summalyProxy,
enableIpLogging,
}).then(() => {
fetchInstance();
});
@ -74,6 +89,5 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.security,
icon: 'fas fa-lock',
bg: 'var(--bg)',
});
</script>

View File

@ -258,6 +258,5 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.general,
icon: 'fas fa-cog',
bg: 'var(--bg)',
});
</script>

View File

@ -135,7 +135,6 @@ const headerTabs = $computed(() => []);
definePageMetadata(computed(() => ({
title: i18n.ts.users,
icon: 'fas fa-users',
bg: 'var(--bg)',
})));
</script>

View File

@ -47,7 +47,6 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.announcements,
icon: 'fas fa-broadcast-tower',
bg: 'var(--bg)',
});
</script>

View File

@ -1,17 +1,20 @@
<template>
<div ref="rootEl" v-hotkey.global="keymap" v-size="{ min: [800] }" class="tqmomfks">
<div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
<div class="tl _block">
<XTimeline
ref="tlEl" :key="antennaId"
class="tl"
src="antenna"
:antenna="antennaId"
:sound="true"
@queue="queueUpdated"
/>
<MkStickyContainer>
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
<div ref="rootEl" v-hotkey.global="keymap" v-size="{ min: [800] }" class="tqmomfks">
<div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div>
<div class="tl _block">
<XTimeline
ref="tlEl" :key="antennaId"
class="tl"
src="antenna"
:antenna="antennaId"
:sound="true"
@queue="queueUpdated"
/>
</div>
</div>
</div>
</MkStickyContainer>
</template>
<script lang="ts" setup>
@ -21,7 +24,7 @@ import { scroll } from '@/scripts/scroll';
import * as os from '@/os';
import { useRouter } from '@/router';
import { definePageMetadata } from '@/scripts/page-metadata';
import i18n from '@/components/global/i18n';
import { i18n } from '@/i18n';
const router = useRouter();
@ -68,23 +71,21 @@ watch(() => props.antennaId, async () => {
});
}, { immediate: true });
const headerActions = $computed(() => []);
const headerActions = $computed(() => antenna ? [{
icon: 'fas fa-calendar-alt',
text: i18n.ts.jumpToSpecifiedDate,
handler: timetravel,
}, {
icon: 'fas fa-cog',
text: i18n.ts.settings,
handler: settings,
}] : []);
const headerTabs = $computed(() => []);
definePageMetadata(computed(() => antenna ? {
title: antenna.name,
icon: 'fas fa-satellite',
bg: 'var(--bg)',
actions: [{
icon: 'fas fa-calendar-alt',
text: i18n.ts.jumpToSpecifiedDate,
handler: timetravel,
}, {
icon: 'fas fa-cog',
text: i18n.ts.settings,
handler: settings,
}],
} : null));
</script>
@ -109,7 +110,7 @@ definePageMetadata(computed(() => antenna ? {
> .tl {
background: var(--bg);
border-radius: var(--radius);
overflow: clip;
overflow: hidden; overflow: clip;
}
&.min-width_800px {

View File

@ -111,11 +111,9 @@ const headerTabs = $computed(() => []);
definePageMetadata(computed(() => props.channelId ? {
title: i18n.ts._channel.edit,
icon: 'fas fa-satellite-dish',
bg: 'var(--bg)',
} : {
title: i18n.ts._channel.create,
icon: 'fas fa-satellite-dish',
bg: 'var(--bg)',
}));
</script>

View File

@ -80,7 +80,6 @@ const headerTabs = $computed(() => []);
definePageMetadata(computed(() => channel ? {
title: channel.name,
icon: 'fas fa-satellite-dish',
bg: 'var(--bg)',
} : null));
</script>

View File

@ -75,6 +75,5 @@ const headerTabs = $computed(() => [{
definePageMetadata(computed(() => ({
title: i18n.ts.channel,
icon: 'fas fa-satellite-dish',
bg: 'var(--bg)',
})));
</script>

View File

@ -102,7 +102,6 @@ const headerActions = $computed(() => clip && isOwned ? [{
definePageMetadata(computed(() => clip ? {
title: clip.name,
icon: 'fas fa-paperclip',
bg: 'var(--bg)',
} : null));
</script>

View File

@ -20,7 +20,6 @@ const headerTabs = $computed(() => []);
definePageMetadata(computed(() => ({
title: folder ? folder.name : i18n.ts.drive,
icon: 'fas fa-cloud',
bg: 'var(--bg)',
hideHeader: true,
})));
</script>

View File

@ -1,6 +1,6 @@
<template>
<MkSpacer :content-max="800">
<MkTab v-model="tab">
<MkTab v-model="tab" style="margin-bottom: var(--margin);">
<option value="notes">{{ i18n.ts.notes }}</option>
<option value="polls">{{ i18n.ts.poll }}</option>
</MkTab>

View File

@ -87,6 +87,5 @@ const headerTabs = $computed(() => [{
definePageMetadata(computed(() => ({
title: i18n.ts.explore,
icon: 'fas fa-hashtag',
bg: 'var(--bg)',
})));
</script>

View File

@ -38,7 +38,6 @@ const pagingComponent = ref<InstanceType<typeof MkPagination>>();
definePageMetadata({
title: i18n.ts.favorites,
icon: 'fas fa-star',
bg: 'var(--bg)',
});
</script>

View File

@ -65,7 +65,6 @@ const headerTabs = $computed(() => []);
definePageMetadata(computed(() => ({
title: i18n.ts.followRequests,
icon: 'fas fa-user-clock',
bg: 'var(--bg)',
})));
</script>

View File

@ -116,11 +116,9 @@ const headerTabs = $computed(() => []);
definePageMetadata(computed(() => props.postId ? {
title: i18n.ts.edit,
icon: 'fas fa-pencil-alt',
bg: 'var(--bg)',
} : {
title: i18n.ts.postToGallery,
icon: 'fas fa-pencil-alt',
bg: 'var(--bg)',
}));
</script>

View File

@ -122,7 +122,6 @@ const headerTabs = $computed(() => [{
definePageMetadata({
title: i18n.ts.gallery,
icon: 'fas fa-icons',
bg: 'var(--bg)',
});
</script>

View File

@ -149,7 +149,6 @@ const headerTabs = $computed(() => []);
definePageMetadata(computed(() => post ? {
title: post.title,
avatar: post.user,
bg: 'var(--bg)',
} : null));
</script>

View File

@ -215,7 +215,6 @@ const headerTabs = $computed(() => [{
definePageMetadata({
title: props.host,
icon: 'fas fa-server',
bg: 'var(--bg)',
});
</script>

View File

@ -159,7 +159,6 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.messaging,
icon: 'fas fa-comments',
bg: 'var(--bg)',
});
</script>

View File

@ -38,7 +38,6 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.manageAntennas,
icon: 'fas fa-satellite',
bg: 'var(--bg)',
});
</script>

View File

@ -34,7 +34,6 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.manageAntennas,
icon: 'fas fa-satellite',
bg: 'var(--bg)',
});
</script>

View File

@ -69,7 +69,6 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.clip,
icon: 'fas fa-paperclip',
bg: 'var(--bg)',
action: {
icon: 'fas fa-plus',
handler: create,

View File

@ -46,7 +46,6 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.manageLists,
icon: 'fas fa-list-ul',
bg: 'var(--bg)',
action: {
icon: 'fas fa-plus',
handler: create,

View File

@ -120,7 +120,6 @@ const headerTabs = $computed(() => []);
definePageMetadata(computed(() => list ? {
title: list.name,
icon: 'fas fa-list-ul',
bg: 'var(--bg)',
} : null));
</script>

Some files were not shown because too many files have changed in this diff Show More