mirror of
https://github.com/misskey-dev/misskey.git
synced 2024-12-26 04:00:18 +01:00
* build(#10336): init
* fix(#10336): invalid name conversion
* build(#10336): load locales and vite config
* refactor(#10336): remove unused imports
* build(#10336): separate definitions and generated codes
* refactor(#10336): remove hatches
* refactor(#10336): module semantics
* refactor(#10336): remove unused common preferences
* fix: typo
* build(#10336): mock assets
* build(#10336): impl `SatisfiesExpression`
* build(#10336): control themes
* refactor(#10336): semantics
* build(#10336): make .storybook as an individual TypeScript project
* style(#10336): use single quote
* build(#10336): avoid intrinsic component names
* chore: suppress linter
* style: typing
* build(#10336): update dependencies
* docs: note about Storybook
* build(#10336): sync
* build(#10336): full reload server on change
* chore: use defaultStore instead
* build(#10336): show popups on Story
* refactor(#10336): remove redundant div
* docs: fix
* build(#10336): interactions
* build(#10336): add an interaction test for `<MkA/>`
* build(#10336): bump storybook
* docs(#10336): mention to pre-build misskey-js
* build(#10336): write stories for `MkAcct`
* build(#10336): write stories for `MkAd`
* build(#10336): fix missing type definition
* build(#10336): use `toHaveTextContent`
* build(#10336): write some stories
* build(#10336): hide internal args
* build(#10336): generate `components/global` stories only
* build(#10336): write stories for `MkMisskeyFlavoredMarkdown`
* fix: conflict errors
* build(#10336): subcomponents on sidebar
* refactor: restore `SatisfiesExpression`
* docs(#10336): note development status
* build(#10336): use chokidar-cli
* docs(#10336): note chokidar-cli mode
* chore(#10336): untrack generated stories files
* fix: pointer handling
* build(#10336): finalize
* chore: add static option to `MkLoading`
* refactor(#10336): bind to local args
* fix: missing case
* revert: restore `SatisfiesExpression`
This reverts commit f246699f38
.
* build(#10336): make storybook buildable
* build(#10336): staticify assets
* build(#10336): staticified directory structure
* build(#10336): normalize path for Windows
* ci(#10336): create actions
* build(#10336): ignore tsc errors
* build(#10336): ignore tsc errors
* build(#10336): missing dependencies
* build(#10336): missing dependencies
* build(#10336): use fast-glob
* fix: invalid lockfile
* ci(#10336): increase heap size
* build(#10336): use unpkg for storybook tabler icons
* build(#10336): use unpkg for storybook twemojis
* build(#10336): disable `ProfilePageCat`
* build(#10336): blur `MkA` before interaction ends
* ci(#10336): stabilize
* ci(#10336): fetch-depth
* build(#10336): isChromatic
* ci(#10336): notify on changes
* ci(#10336): fix typo
* ci(#10336): missing working directory
* ci(#10336): skip build
* ci(#10336): fix path
* build(#10336): fails on Windows
* build(#10336): available on Windows
* ci(#10336): disable animation on chromatic
* ci(#10336): add static option to `PageHeader.tabs`
* chore: void
* ci(#10336): change parameters
* docs(#10336): update CONTRIBUTING
* docs(#10336): note about meta overriding and etc.
* ci(#10336): use Chromatic for checks
* ci(#10336): use `pull_request` instead of `pull_request_target` for now
* ci(#10336): use `exitOnceUploaded`
* ci(#10336): reuse built storybook
* ci(#10336): back to `pull_request_target`
* chore: unused dependencies
* style(#10336): reduce prettier indents
* style: note about `TSSatisfiesExpression`
This commit is contained in:
parent
8a0201fe9c
commit
38d0b62167
59 changed files with 7708 additions and 365 deletions
56
.github/workflows/storybook.yml
vendored
Normal file
56
.github/workflows/storybook.yml
vendored
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
name: Storybook
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
- develop
|
||||||
|
pull_request_target:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3.3.0
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
submodules: true
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@v2
|
||||||
|
with:
|
||||||
|
version: 7
|
||||||
|
run_install: false
|
||||||
|
- name: Use Node.js 18.x
|
||||||
|
uses: actions/setup-node@v3.6.0
|
||||||
|
with:
|
||||||
|
node-version: 18.x
|
||||||
|
cache: 'pnpm'
|
||||||
|
- run: corepack enable
|
||||||
|
- run: pnpm i --frozen-lockfile
|
||||||
|
- name: Check pnpm-lock.yaml
|
||||||
|
run: git diff --exit-code pnpm-lock.yaml
|
||||||
|
- name: Build misskey-js
|
||||||
|
run: pnpm --filter misskey-js build
|
||||||
|
- name: Build storybook
|
||||||
|
run: pnpm --filter frontend build-storybook
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: "--max_old_space_size=7168"
|
||||||
|
- name: Publish to Chromatic
|
||||||
|
id: chromatic
|
||||||
|
uses: chromaui/action@v1
|
||||||
|
with:
|
||||||
|
exitOnceUploaded: true
|
||||||
|
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||||
|
storybookBuildDir: storybook-static
|
||||||
|
workingDir: packages/frontend
|
||||||
|
- name: Compare on Chromatic
|
||||||
|
if: github.event_name == 'pull_request_target'
|
||||||
|
run: pnpm --filter frontend chromatic -d storybook-static --exit-once-uploaded --patch-build ${{ github.head_ref }}...${{ github.base_ref }}
|
||||||
|
env:
|
||||||
|
CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||||
|
- name: Upload Artifacts
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: storybook
|
||||||
|
path: packages/frontend/storybook-static
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -56,6 +56,7 @@ api-docs.json
|
||||||
/files
|
/files
|
||||||
ormconfig.json
|
ormconfig.json
|
||||||
temp
|
temp
|
||||||
|
/packages/frontend/src/**/*.stories.ts
|
||||||
|
|
||||||
# blender backups
|
# blender backups
|
||||||
*.blend1
|
*.blend1
|
||||||
|
|
110
CONTRIBUTING.md
110
CONTRIBUTING.md
|
@ -203,6 +203,116 @@ niraxは、Misskeyで使用しているオリジナルのフロントエンド
|
||||||
vue-routerとの最大の違いは、niraxは複数のルーターが存在することを許可している点です。
|
vue-routerとの最大の違いは、niraxは複数のルーターが存在することを許可している点です。
|
||||||
これにより、アプリ内ウィンドウでブラウザとは個別にルーティングすることなどが可能になります。
|
これにより、アプリ内ウィンドウでブラウザとは個別にルーティングすることなどが可能になります。
|
||||||
|
|
||||||
|
## Storybook
|
||||||
|
|
||||||
|
Misskey uses [Storybook](https://storybook.js.org/) for UI development.
|
||||||
|
|
||||||
|
### Setup & Run
|
||||||
|
|
||||||
|
#### Universal
|
||||||
|
|
||||||
|
##### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter misskey-js build
|
||||||
|
pnpm --filter frontend tsc -p .storybook && (node packages/frontend/.storybook/preload-locale.js & node packages/frontend/.storybook/preload-theme.js)
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node packages/frontend/.storybook/generate.js && pnpm --filter frontend storybook dev
|
||||||
|
```
|
||||||
|
|
||||||
|
#### macOS & Linux
|
||||||
|
|
||||||
|
##### Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter misskey-js build
|
||||||
|
```
|
||||||
|
|
||||||
|
##### Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter frontend storybook-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
When you create a new component (in this example, `MyComponent.vue`), the story file (`MyComponent.stories.ts`) will be automatically generated by the `.storybook/generate.js` script.
|
||||||
|
You can override the default story by creating a impl story file (`MyComponent.stories.impl.ts`).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
/* eslint-disable import/no-duplicates */
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import MyComponent from './MyComponent.vue';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MyComponent,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MyComponent v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
foo: 'bar',
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAvatar>;
|
||||||
|
```
|
||||||
|
|
||||||
|
If you want to opt-out from the automatic generation, create a `MyComponent.stories.impl.ts` file and add the following line to the file.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import MyComponent from './MyComponent.vue';
|
||||||
|
void MyComponent;
|
||||||
|
```
|
||||||
|
|
||||||
|
You can override the component meta by creating a meta story file (`MyComponent.stories.meta.ts`).
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const argTypes = {
|
||||||
|
scale: {
|
||||||
|
control: {
|
||||||
|
type: 'range',
|
||||||
|
min: 1,
|
||||||
|
max: 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Also, you can use msw to mock API requests in the storybook. Creating a `MyComponent.stories.msw.ts` file to define the mock handlers.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { rest } from 'msw';
|
||||||
|
export const handlers = [
|
||||||
|
rest.post('/api/notes/timeline', (req, res, ctx) => {
|
||||||
|
return res(
|
||||||
|
ctx.json([]),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
Don't forget to re-run the `.storybook/generate.js` script after adding, editing, or removing the above files.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
### How to resolve conflictions occurred at pnpm-lock.yaml?
|
### How to resolve conflictions occurred at pnpm-lock.yaml?
|
||||||
|
|
||||||
|
|
1
packages/frontend/.gitignore
vendored
Normal file
1
packages/frontend/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/storybook-static
|
9
packages/frontend/.storybook/.gitignore
vendored
Normal file
9
packages/frontend/.storybook/.gitignore
vendored
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
# (cd path/to/frontend; pnpm tsc -p .storybook)
|
||||||
|
# (cd path/to/frontend; node .storybook/generate.js)
|
||||||
|
/generate.js
|
||||||
|
# (cd path/to/frontend; node .storybook/preload-locale.js)
|
||||||
|
/preload-locale.js
|
||||||
|
/locale.ts
|
||||||
|
# (cd path/to/frontend; node .storybook/preload-theme.js)
|
||||||
|
/preload-theme.js
|
||||||
|
/themes.ts
|
54
packages/frontend/.storybook/fakes.ts
Normal file
54
packages/frontend/.storybook/fakes.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import type { entities } from 'misskey-js'
|
||||||
|
|
||||||
|
export const userDetailed = {
|
||||||
|
id: 'someuserid',
|
||||||
|
username: 'miskist',
|
||||||
|
host: 'misskey-hub.net',
|
||||||
|
name: 'Misskey User',
|
||||||
|
onlineStatus: 'unknown',
|
||||||
|
avatarUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
|
||||||
|
avatarBlurhash: 'eQFRshof5NWBRi},juayfPju53WB?0ofs;s*a{ofjuay^SoMEJR%ay',
|
||||||
|
emojis: [],
|
||||||
|
bannerBlurhash: 'eQA^IW^-MH8w9tE8I=S^o{$*R4RikXtSxutRozjEnNR.RQadoyozog',
|
||||||
|
bannerColor: '#000000',
|
||||||
|
bannerUrl: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true',
|
||||||
|
birthday: '2014-06-20',
|
||||||
|
createdAt: '2016-12-28T22:49:51.000Z',
|
||||||
|
description: 'I am a cool user!',
|
||||||
|
ffVisibility: 'public',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'Website',
|
||||||
|
value: 'https://misskey-hub.net',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
followersCount: 1024,
|
||||||
|
followingCount: 16,
|
||||||
|
hasPendingFollowRequestFromYou: false,
|
||||||
|
hasPendingFollowRequestToYou: false,
|
||||||
|
isAdmin: false,
|
||||||
|
isBlocked: false,
|
||||||
|
isBlocking: false,
|
||||||
|
isBot: false,
|
||||||
|
isCat: false,
|
||||||
|
isFollowed: false,
|
||||||
|
isFollowing: false,
|
||||||
|
isLocked: false,
|
||||||
|
isModerator: false,
|
||||||
|
isMuted: false,
|
||||||
|
isSilenced: false,
|
||||||
|
isSuspended: false,
|
||||||
|
lang: 'en',
|
||||||
|
location: 'Fediverse',
|
||||||
|
notesCount: 65536,
|
||||||
|
pinnedNoteIds: [],
|
||||||
|
pinnedNotes: [],
|
||||||
|
pinnedPage: null,
|
||||||
|
pinnedPageId: null,
|
||||||
|
publicReactions: false,
|
||||||
|
securityKeys: false,
|
||||||
|
twoFactorEnabled: false,
|
||||||
|
updatedAt: null,
|
||||||
|
uri: null,
|
||||||
|
url: null,
|
||||||
|
} satisfies entities.UserDetailed
|
406
packages/frontend/.storybook/generate.tsx
Normal file
406
packages/frontend/.storybook/generate.tsx
Normal file
|
@ -0,0 +1,406 @@
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import { basename, dirname } from 'node:path/posix';
|
||||||
|
import { GENERATOR, type State, generate } from 'astring';
|
||||||
|
import type * as estree from 'estree';
|
||||||
|
import glob from 'fast-glob';
|
||||||
|
import { format } from 'prettier';
|
||||||
|
|
||||||
|
interface SatisfiesExpression extends estree.BaseExpression {
|
||||||
|
type: 'SatisfiesExpression';
|
||||||
|
expression: estree.Expression;
|
||||||
|
reference: estree.Identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generator = {
|
||||||
|
...GENERATOR,
|
||||||
|
SatisfiesExpression(node: SatisfiesExpression, state: State) {
|
||||||
|
switch (node.expression.type) {
|
||||||
|
case 'ArrowFunctionExpression': {
|
||||||
|
state.write('(');
|
||||||
|
this[node.expression.type](node.expression, state);
|
||||||
|
state.write(')');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
// @ts-ignore
|
||||||
|
this[node.expression.type](node.expression, state);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.write(' satisfies ', node as unknown as estree.Expression);
|
||||||
|
this[node.reference.type](node.reference, state);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type SplitCamel<
|
||||||
|
T extends string,
|
||||||
|
YC extends string = '',
|
||||||
|
YN extends readonly string[] = []
|
||||||
|
> = T extends `${infer XH}${infer XR}`
|
||||||
|
? XR extends ''
|
||||||
|
? [...YN, Uncapitalize<`${YC}${XH}`>]
|
||||||
|
: XH extends Uppercase<XH>
|
||||||
|
? SplitCamel<XR, Lowercase<XH>, [...YN, YC]>
|
||||||
|
: SplitCamel<XR, `${YC}${XH}`, YN>
|
||||||
|
: YN;
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
type SplitKebab<T extends string> = T extends `${infer XH}-${infer XR}`
|
||||||
|
? [XH, ...SplitKebab<XR>]
|
||||||
|
: [T];
|
||||||
|
|
||||||
|
type ToKebab<T extends readonly string[]> = T extends readonly [
|
||||||
|
infer XO extends string
|
||||||
|
]
|
||||||
|
? XO
|
||||||
|
: T extends readonly [
|
||||||
|
infer XH extends string,
|
||||||
|
...infer XR extends readonly string[]
|
||||||
|
]
|
||||||
|
? `${XH}${XR extends readonly string[] ? `-${ToKebab<XR>}` : ''}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
type ToPascal<T extends readonly string[]> = T extends readonly [
|
||||||
|
infer XH extends string,
|
||||||
|
...infer XR extends readonly string[]
|
||||||
|
]
|
||||||
|
? `${Capitalize<XH>}${ToPascal<XR>}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
function h<T extends estree.Node>(
|
||||||
|
component: T['type'],
|
||||||
|
props: Omit<T, 'type'>
|
||||||
|
): T {
|
||||||
|
const type = component.replace(/(?:^|-)([a-z])/g, (_, c) => c.toUpperCase());
|
||||||
|
return Object.assign(props || {}, { type }) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace JSX {
|
||||||
|
type Element = estree.Node;
|
||||||
|
type ElementClass = never;
|
||||||
|
type ElementAttributesProperty = never;
|
||||||
|
type ElementChildrenAttribute = never;
|
||||||
|
type IntrinsicAttributes = never;
|
||||||
|
type IntrinsicClassAttributes<T> = never;
|
||||||
|
type IntrinsicElements = {
|
||||||
|
[T in keyof typeof generator as ToKebab<SplitCamel<Uncapitalize<T>>>]: {
|
||||||
|
[K in keyof Omit<
|
||||||
|
Parameters<(typeof generator)[T]>[0],
|
||||||
|
'type'
|
||||||
|
>]?: Parameters<(typeof generator)[T]>[0][K];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStories(component: string): string {
|
||||||
|
const msw = `${component.slice(0, -'.vue'.length)}.msw`;
|
||||||
|
const implStories = `${component.slice(0, -'.vue'.length)}.stories.impl`;
|
||||||
|
const metaStories = `${component.slice(0, -'.vue'.length)}.stories.meta`;
|
||||||
|
const hasMsw = existsSync(`${msw}.ts`);
|
||||||
|
const hasImplStories = existsSync(`${implStories}.ts`);
|
||||||
|
const hasMetaStories = existsSync(`${metaStories}.ts`);
|
||||||
|
const base = basename(component);
|
||||||
|
const dir = dirname(component);
|
||||||
|
const literal =
|
||||||
|
<literal
|
||||||
|
value={component
|
||||||
|
.slice('src/'.length, -'.vue'.length)
|
||||||
|
.replace(/\./g, '/')}
|
||||||
|
/> as estree.Literal;
|
||||||
|
const identifier =
|
||||||
|
<identifier
|
||||||
|
name={base
|
||||||
|
.slice(0, -'.vue'.length)
|
||||||
|
.replace(/[-.]|^(?=\d)/g, '_')
|
||||||
|
.replace(/(?<=^[^A-Z_]*$)/, '_')}
|
||||||
|
/> as estree.Identifier;
|
||||||
|
const parameters = (
|
||||||
|
<object-expression
|
||||||
|
properties={[
|
||||||
|
<property
|
||||||
|
key={<identifier name='layout' /> as estree.Identifier}
|
||||||
|
value={<literal value={`${dir}/`.startsWith('src/pages/') ? 'fullscreen' : 'centered'}/> as estree.Literal}
|
||||||
|
kind={'init' as const}
|
||||||
|
/> as estree.Property,
|
||||||
|
...(hasMsw
|
||||||
|
? [
|
||||||
|
<property
|
||||||
|
key={<identifier name='msw' /> as estree.Identifier}
|
||||||
|
value={<identifier name='msw' /> as estree.Identifier}
|
||||||
|
kind={'init' as const}
|
||||||
|
shorthand
|
||||||
|
/> as estree.Property,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) as estree.ObjectExpression;
|
||||||
|
const program = (
|
||||||
|
<program
|
||||||
|
body={[
|
||||||
|
<import-declaration
|
||||||
|
source={<literal value='@storybook/vue3' /> as estree.Literal}
|
||||||
|
specifiers={[
|
||||||
|
<import-specifier
|
||||||
|
local={<identifier name='Meta' /> as estree.Identifier}
|
||||||
|
imported={<identifier name='Meta' /> as estree.Identifier}
|
||||||
|
/> as estree.ImportSpecifier,
|
||||||
|
...(hasImplStories
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
<import-specifier
|
||||||
|
local={<identifier name='StoryObj' /> as estree.Identifier}
|
||||||
|
imported={<identifier name='StoryObj' /> as estree.Identifier}
|
||||||
|
/> as estree.ImportSpecifier,
|
||||||
|
]),
|
||||||
|
]}
|
||||||
|
/> as estree.ImportDeclaration,
|
||||||
|
...(hasMsw
|
||||||
|
? [
|
||||||
|
<import-declaration
|
||||||
|
source={<literal value={`./${basename(msw)}`} /> as estree.Literal}
|
||||||
|
specifiers={[
|
||||||
|
<import-namespace-specifier
|
||||||
|
local={<identifier name='msw' /> as estree.Identifier}
|
||||||
|
/> as estree.ImportNamespaceSpecifier,
|
||||||
|
]}
|
||||||
|
/> as estree.ImportDeclaration,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(hasImplStories
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
<import-declaration
|
||||||
|
source={<literal value={`./${base}`} /> as estree.Literal}
|
||||||
|
specifiers={[
|
||||||
|
<import-default-specifier local={identifier} /> as estree.ImportDefaultSpecifier,
|
||||||
|
]}
|
||||||
|
/> as estree.ImportDeclaration,
|
||||||
|
]),
|
||||||
|
...(hasMetaStories
|
||||||
|
? [
|
||||||
|
<import-declaration
|
||||||
|
source={<literal value={`./${basename(metaStories)}`} /> as estree.Literal}
|
||||||
|
specifiers={[
|
||||||
|
<import-namespace-specifier
|
||||||
|
local={<identifier name='storiesMeta' /> as estree.Identifier}
|
||||||
|
/> as estree.ImportNamespaceSpecifier,
|
||||||
|
]}
|
||||||
|
/> as estree.ImportDeclaration,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
<variable-declaration
|
||||||
|
kind={'const' as const}
|
||||||
|
declarations={[
|
||||||
|
<variable-declarator
|
||||||
|
id={<identifier name='meta' /> as estree.Identifier}
|
||||||
|
init={
|
||||||
|
<satisfies-expression
|
||||||
|
expression={
|
||||||
|
<object-expression
|
||||||
|
properties={[
|
||||||
|
<property
|
||||||
|
key={<identifier name='title' /> as estree.Identifier}
|
||||||
|
value={literal}
|
||||||
|
kind={'init' as const}
|
||||||
|
/> as estree.Property,
|
||||||
|
<property
|
||||||
|
key={<identifier name='component' /> as estree.Identifier}
|
||||||
|
value={identifier}
|
||||||
|
kind={'init' as const}
|
||||||
|
/> as estree.Property,
|
||||||
|
...(hasMetaStories
|
||||||
|
? [
|
||||||
|
<spread-element
|
||||||
|
argument={<identifier name='storiesMeta' /> as estree.Identifier}
|
||||||
|
/> as estree.SpreadElement,
|
||||||
|
]
|
||||||
|
: [])
|
||||||
|
]}
|
||||||
|
/> as estree.ObjectExpression
|
||||||
|
}
|
||||||
|
reference={<identifier name={`Meta<typeof ${identifier.name}>`} /> as estree.Identifier}
|
||||||
|
/> as estree.Expression
|
||||||
|
}
|
||||||
|
/> as estree.VariableDeclarator,
|
||||||
|
]}
|
||||||
|
/> as estree.VariableDeclaration,
|
||||||
|
...(hasImplStories
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
<export-named-declaration
|
||||||
|
declaration={
|
||||||
|
<variable-declaration
|
||||||
|
kind={'const' as const}
|
||||||
|
declarations={[
|
||||||
|
<variable-declarator
|
||||||
|
id={<identifier name='Default' /> as estree.Identifier}
|
||||||
|
init={
|
||||||
|
<satisfies-expression
|
||||||
|
expression={
|
||||||
|
<object-expression
|
||||||
|
properties={[
|
||||||
|
<property
|
||||||
|
key={<identifier name='render' /> as estree.Identifier}
|
||||||
|
value={
|
||||||
|
<function-expression
|
||||||
|
params={[
|
||||||
|
<identifier name='args' /> as estree.Identifier,
|
||||||
|
]}
|
||||||
|
body={
|
||||||
|
<block-statement
|
||||||
|
body={[
|
||||||
|
<return-statement
|
||||||
|
argument={
|
||||||
|
<object-expression
|
||||||
|
properties={[
|
||||||
|
<property
|
||||||
|
key={<identifier name='components' /> as estree.Identifier}
|
||||||
|
value={
|
||||||
|
<object-expression
|
||||||
|
properties={[
|
||||||
|
<property key={identifier} value={identifier} kind={'init' as const} shorthand /> as estree.Property,
|
||||||
|
]}
|
||||||
|
/> as estree.ObjectExpression
|
||||||
|
}
|
||||||
|
kind={'init' as const}
|
||||||
|
/> as estree.Property,
|
||||||
|
<property
|
||||||
|
key={<identifier name='setup' /> as estree.Identifier}
|
||||||
|
value={
|
||||||
|
<function-expression
|
||||||
|
params={[]}
|
||||||
|
body={
|
||||||
|
<block-statement
|
||||||
|
body={[
|
||||||
|
<return-statement
|
||||||
|
argument={
|
||||||
|
<object-expression
|
||||||
|
properties={[
|
||||||
|
<property
|
||||||
|
key={<identifier name='args' /> as estree.Identifier}
|
||||||
|
value={<identifier name='args' /> as estree.Identifier}
|
||||||
|
kind={'init' as const}
|
||||||
|
shorthand
|
||||||
|
/> as estree.Property,
|
||||||
|
]}
|
||||||
|
/> as estree.ObjectExpression
|
||||||
|
}
|
||||||
|
/> as estree.ReturnStatement,
|
||||||
|
]}
|
||||||
|
/> as estree.BlockStatement
|
||||||
|
}
|
||||||
|
/> as estree.FunctionExpression
|
||||||
|
}
|
||||||
|
method
|
||||||
|
kind={'init' as const}
|
||||||
|
/> as estree.Property,
|
||||||
|
<property
|
||||||
|
key={<identifier name='computed' /> as estree.Identifier}
|
||||||
|
value={
|
||||||
|
<object-expression
|
||||||
|
properties={[
|
||||||
|
<property
|
||||||
|
key={<identifier name='props' /> as estree.Identifier}
|
||||||
|
value={
|
||||||
|
<function-expression
|
||||||
|
params={[]}
|
||||||
|
body={
|
||||||
|
<block-statement
|
||||||
|
body={[
|
||||||
|
<return-statement
|
||||||
|
argument={
|
||||||
|
<object-expression
|
||||||
|
properties={[
|
||||||
|
<spread-element
|
||||||
|
argument={
|
||||||
|
<member-expression
|
||||||
|
object={<this-expression /> as estree.ThisExpression}
|
||||||
|
property={<identifier name='args' /> as estree.Identifier}
|
||||||
|
/> as estree.MemberExpression
|
||||||
|
}
|
||||||
|
/> as estree.SpreadElement,
|
||||||
|
]}
|
||||||
|
/> as estree.ObjectExpression
|
||||||
|
}
|
||||||
|
/> as estree.ReturnStatement,
|
||||||
|
]}
|
||||||
|
/> as estree.BlockStatement
|
||||||
|
}
|
||||||
|
/> as estree.FunctionExpression
|
||||||
|
}
|
||||||
|
method
|
||||||
|
kind={'init' as const}
|
||||||
|
/> as estree.Property,
|
||||||
|
]}
|
||||||
|
/> as estree.ObjectExpression
|
||||||
|
}
|
||||||
|
kind={'init' as const}
|
||||||
|
/> as estree.Property,
|
||||||
|
<property
|
||||||
|
key={<identifier name='template' /> as estree.Identifier}
|
||||||
|
value={<literal value={`<${identifier.name} v-bind="props" />`} /> as estree.Literal}
|
||||||
|
kind={'init' as const}
|
||||||
|
/> as estree.Property,
|
||||||
|
]}
|
||||||
|
/> as estree.ObjectExpression
|
||||||
|
}
|
||||||
|
/> as estree.ReturnStatement,
|
||||||
|
]}
|
||||||
|
/> as estree.BlockStatement
|
||||||
|
}
|
||||||
|
/> as estree.FunctionExpression
|
||||||
|
}
|
||||||
|
method
|
||||||
|
kind={'init' as const}
|
||||||
|
/> as estree.Property,
|
||||||
|
<property
|
||||||
|
key={<identifier name='parameters' /> as estree.Identifier}
|
||||||
|
value={parameters}
|
||||||
|
kind={'init' as const}
|
||||||
|
/> as estree.Property,
|
||||||
|
]}
|
||||||
|
/> as estree.ObjectExpression
|
||||||
|
}
|
||||||
|
reference={<identifier name={`StoryObj<typeof ${identifier.name}>`} /> as estree.Identifier}
|
||||||
|
/> as estree.Expression
|
||||||
|
}
|
||||||
|
/> as estree.VariableDeclarator,
|
||||||
|
]}
|
||||||
|
/> as estree.VariableDeclaration
|
||||||
|
}
|
||||||
|
/> as estree.ExportNamedDeclaration,
|
||||||
|
]),
|
||||||
|
<export-default-declaration
|
||||||
|
declaration={(<identifier name='meta' />) as estree.Identifier}
|
||||||
|
/> as estree.ExportDefaultDeclaration,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) as estree.Program;
|
||||||
|
return format(
|
||||||
|
'/* eslint-disable @typescript-eslint/explicit-function-return-type */\n' +
|
||||||
|
'/* eslint-disable import/no-default-export */\n' +
|
||||||
|
generate(program, { generator }) +
|
||||||
|
(hasImplStories ? readFileSync(`${implStories}.ts`, 'utf-8') : ''),
|
||||||
|
{
|
||||||
|
parser: 'babel-ts',
|
||||||
|
singleQuote: true,
|
||||||
|
useTabs: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// promisify(glob)('src/{components,pages,ui,widgets}/**/*.vue').then(
|
||||||
|
glob('src/components/global/**/*.vue').then(
|
||||||
|
(components) =>
|
||||||
|
Promise.all(
|
||||||
|
components.map((component) => {
|
||||||
|
const stories = component.replace(/\.vue$/, '.stories.ts');
|
||||||
|
return writeFile(stories, toStories(component));
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
35
packages/frontend/.storybook/main.ts
Normal file
35
packages/frontend/.storybook/main.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import type { StorybookConfig } from '@storybook/vue3-vite';
|
||||||
|
import { mergeConfig } from 'vite';
|
||||||
|
const config = {
|
||||||
|
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
|
||||||
|
addons: [
|
||||||
|
'@storybook/addon-essentials',
|
||||||
|
'@storybook/addon-interactions',
|
||||||
|
'@storybook/addon-links',
|
||||||
|
'@storybook/addon-storysource',
|
||||||
|
resolve(__dirname, '../node_modules/storybook-addon-misskey-theme'),
|
||||||
|
],
|
||||||
|
framework: {
|
||||||
|
name: '@storybook/vue3-vite',
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
docs: {
|
||||||
|
autodocs: 'tag',
|
||||||
|
},
|
||||||
|
core: {
|
||||||
|
disableTelemetry: true,
|
||||||
|
},
|
||||||
|
async viteFinal(config, options) {
|
||||||
|
return mergeConfig(config, {
|
||||||
|
build: {
|
||||||
|
target: [
|
||||||
|
'chrome108',
|
||||||
|
'firefox109',
|
||||||
|
'safari16',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
} satisfies StorybookConfig;
|
||||||
|
export default config;
|
12
packages/frontend/.storybook/manager.ts
Normal file
12
packages/frontend/.storybook/manager.ts
Normal file
File diff suppressed because one or more lines are too long
16
packages/frontend/.storybook/mocks.ts
Normal file
16
packages/frontend/.storybook/mocks.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { type SharedOptions, rest } from 'msw';
|
||||||
|
|
||||||
|
export const onUnhandledRequest = ((req, print) => {
|
||||||
|
if (req.url.hostname !== 'localhost' || /^\/(?:client-assets\/|fluent-emojis?\/|iframe.html$|node_modules\/|src\/|sb-|static-assets\/|vite\/)/.test(req.url.pathname)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
print.warning()
|
||||||
|
}) satisfies SharedOptions['onUnhandledRequest'];
|
||||||
|
|
||||||
|
export const commonHandlers = [
|
||||||
|
rest.get('/twemoji/:codepoints.svg', async (req, res, ctx) => {
|
||||||
|
const { codepoints } = req.params;
|
||||||
|
const value = await fetch(`https://unpkg.com/@discordapp/twemoji@14.1.2/dist/svg/${codepoints}.svg`).then((response) => response.blob());
|
||||||
|
return res(ctx.set('Content-Type', 'image/svg+xml'), ctx.body(value));
|
||||||
|
}),
|
||||||
|
];
|
9
packages/frontend/.storybook/preload-locale.ts
Normal file
9
packages/frontend/.storybook/preload-locale.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import * as locales from '../../../locales';
|
||||||
|
|
||||||
|
writeFile(
|
||||||
|
resolve(__dirname, 'locale.ts'),
|
||||||
|
`export default ${JSON.stringify(locales['ja-JP'], undefined, 2)} as const;`,
|
||||||
|
'utf8',
|
||||||
|
)
|
39
packages/frontend/.storybook/preload-theme.ts
Normal file
39
packages/frontend/.storybook/preload-theme.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import * as JSON5 from 'json5';
|
||||||
|
|
||||||
|
const keys = [
|
||||||
|
'_dark',
|
||||||
|
'_light',
|
||||||
|
'l-light',
|
||||||
|
'l-coffee',
|
||||||
|
'l-apricot',
|
||||||
|
'l-rainy',
|
||||||
|
'l-botanical',
|
||||||
|
'l-vivid',
|
||||||
|
'l-cherry',
|
||||||
|
'l-sushi',
|
||||||
|
'l-u0',
|
||||||
|
'd-dark',
|
||||||
|
'd-persimmon',
|
||||||
|
'd-astro',
|
||||||
|
'd-future',
|
||||||
|
'd-botanical',
|
||||||
|
'd-green-lime',
|
||||||
|
'd-green-orange',
|
||||||
|
'd-cherry',
|
||||||
|
'd-ice',
|
||||||
|
'd-u0',
|
||||||
|
]
|
||||||
|
|
||||||
|
Promise.all(keys.map((key) => readFile(resolve(__dirname, `../src/themes/${key}.json5`), 'utf8'))).then((sources) => {
|
||||||
|
writeFile(
|
||||||
|
resolve(__dirname, './themes.ts'),
|
||||||
|
`export default ${JSON.stringify(
|
||||||
|
Object.fromEntries(sources.map((source, i) => [keys[i], JSON5.parse(source)])),
|
||||||
|
undefined,
|
||||||
|
2,
|
||||||
|
)} as const;`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
});
|
4
packages/frontend/.storybook/preview-head.html
Normal file
4
packages/frontend/.storybook/preview-head.html
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/@tabler/icons-webfont@2.12.0/tabler-icons.min.css">
|
||||||
|
<script>
|
||||||
|
window.global = window;
|
||||||
|
</script>
|
113
packages/frontend/.storybook/preview.ts
Normal file
113
packages/frontend/.storybook/preview.ts
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
import { addons } from '@storybook/addons';
|
||||||
|
import { FORCE_REMOUNT } from '@storybook/core-events';
|
||||||
|
import { type Preview, setup } from '@storybook/vue3';
|
||||||
|
import isChromatic from 'chromatic/isChromatic';
|
||||||
|
import { initialize, mswDecorator } from 'msw-storybook-addon';
|
||||||
|
import locale from './locale';
|
||||||
|
import { commonHandlers, onUnhandledRequest } from './mocks';
|
||||||
|
import themes from './themes';
|
||||||
|
import '../src/style.scss';
|
||||||
|
|
||||||
|
const appInitialized = Symbol();
|
||||||
|
|
||||||
|
let moduleInitialized = false;
|
||||||
|
let unobserve = () => {};
|
||||||
|
let misskeyOS = null;
|
||||||
|
|
||||||
|
function loadTheme(applyTheme: typeof import('../src/scripts/theme')['applyTheme']) {
|
||||||
|
unobserve();
|
||||||
|
const theme = themes[document.documentElement.dataset.misskeyTheme];
|
||||||
|
if (theme) {
|
||||||
|
applyTheme(themes[document.documentElement.dataset.misskeyTheme]);
|
||||||
|
} else if (isChromatic()) {
|
||||||
|
applyTheme(themes['l-light']);
|
||||||
|
}
|
||||||
|
const observer = new MutationObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.attributeName === 'data-misskey-theme') {
|
||||||
|
const target = entry.target as HTMLElement;
|
||||||
|
const theme = themes[target.dataset.misskeyTheme];
|
||||||
|
if (theme) {
|
||||||
|
applyTheme(themes[target.dataset.misskeyTheme]);
|
||||||
|
} else {
|
||||||
|
target.removeAttribute('style');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['data-misskey-theme'],
|
||||||
|
});
|
||||||
|
unobserve = () => observer.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
initialize({
|
||||||
|
onUnhandledRequest,
|
||||||
|
});
|
||||||
|
localStorage.setItem("locale", JSON.stringify(locale));
|
||||||
|
queueMicrotask(() => {
|
||||||
|
Promise.all([
|
||||||
|
import('../src/components'),
|
||||||
|
import('../src/directives'),
|
||||||
|
import('../src/widgets'),
|
||||||
|
import('../src/scripts/theme'),
|
||||||
|
import('../src/store'),
|
||||||
|
import('../src/os'),
|
||||||
|
]).then(([{ default: components }, { default: directives }, { default: widgets }, { applyTheme }, { defaultStore }, os]) => {
|
||||||
|
setup((app) => {
|
||||||
|
moduleInitialized = true;
|
||||||
|
if (app[appInitialized]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app[appInitialized] = true;
|
||||||
|
loadTheme(applyTheme);
|
||||||
|
components(app);
|
||||||
|
directives(app);
|
||||||
|
widgets(app);
|
||||||
|
misskeyOS = os;
|
||||||
|
if (isChromatic()) {
|
||||||
|
defaultStore.set('animation', false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const preview = {
|
||||||
|
decorators: [
|
||||||
|
(Story, context) => {
|
||||||
|
const story = Story();
|
||||||
|
if (!moduleInitialized) {
|
||||||
|
const channel = addons.getChannel();
|
||||||
|
(globalThis.requestIdleCallback || setTimeout)(() => {
|
||||||
|
channel.emit(FORCE_REMOUNT, { storyId: context.id });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return story;
|
||||||
|
},
|
||||||
|
mswDecorator,
|
||||||
|
(Story, context) => {
|
||||||
|
return {
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
context,
|
||||||
|
popups: misskeyOS.popups,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
template:
|
||||||
|
'<component :is="popup.component" v-for="popup in popups" :key="popup.id" v-bind="popup.props" v-on="popup.events"/>' +
|
||||||
|
'<story />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
],
|
||||||
|
parameters: {
|
||||||
|
controls: {
|
||||||
|
exclude: /^__/,
|
||||||
|
},
|
||||||
|
msw: {
|
||||||
|
handlers: commonHandlers,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Preview;
|
||||||
|
|
||||||
|
export default preview;
|
22
packages/frontend/.storybook/tsconfig.json
Normal file
22
packages/frontend/.storybook/tsconfig.json
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"allowUnusedLabels": false,
|
||||||
|
"allowUnreachableCode": false,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"jsx": "react",
|
||||||
|
"jsxFactory": "h"
|
||||||
|
},
|
||||||
|
"files": ["./generate.tsx", "./preload-locale.ts", "./preload-theme.ts"]
|
||||||
|
}
|
|
@ -4,6 +4,9 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"watch": "vite",
|
"watch": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"storybook-dev": "chokidar 'src/**/*.{mdx,ts,vue}' -d 1000 -t 1000 --initial -i '**/*.stories.ts' -c 'pkill -f node_modules/storybook/index.js; node_modules/.bin/tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && node_modules/.bin/storybook dev -p 6006 --ci'",
|
||||||
|
"build-storybook": "tsc -p .storybook && node .storybook/generate.js && node .storybook/preload-locale.js && node .storybook/preload-theme.js && storybook build",
|
||||||
|
"chromatic": "chromatic",
|
||||||
"test": "vitest --run",
|
"test": "vitest --run",
|
||||||
"test-and-coverage": "vitest --run --coverage",
|
"test-and-coverage": "vitest --run --coverage",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "vue-tsc --noEmit",
|
||||||
|
@ -71,8 +74,27 @@
|
||||||
"vuedraggable": "next"
|
"vuedraggable": "next"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@storybook/addon-essentials": "7.0.0-rc.10",
|
||||||
|
"@storybook/addon-interactions": "7.0.0-rc.10",
|
||||||
|
"@storybook/addon-links": "7.0.0-rc.10",
|
||||||
|
"@storybook/addon-storysource": "7.0.0-rc.10",
|
||||||
|
"@storybook/addons": "7.0.0-rc.10",
|
||||||
|
"@storybook/blocks": "7.0.0-rc.10",
|
||||||
|
"@storybook/core-events": "7.0.0-rc.10",
|
||||||
|
"@storybook/jest": "0.0.10",
|
||||||
|
"@storybook/manager-api": "7.0.0-rc.10",
|
||||||
|
"@storybook/preview-api": "7.0.0-rc.10",
|
||||||
|
"@storybook/react": "7.0.0-rc.10",
|
||||||
|
"@storybook/react-vite": "7.0.0-rc.10",
|
||||||
|
"@storybook/testing-library": "0.0.14-next.1",
|
||||||
|
"@storybook/theming": "7.0.0-rc.10",
|
||||||
|
"@storybook/types": "7.0.0-rc.10",
|
||||||
|
"@storybook/vue3": "7.0.0-rc.10",
|
||||||
|
"@storybook/vue3-vite": "7.0.0-rc.10",
|
||||||
|
"@testing-library/jest-dom": "^5.16.5",
|
||||||
"@testing-library/vue": "^6.6.1",
|
"@testing-library/vue": "^6.6.1",
|
||||||
"@types/escape-regexp": "0.0.1",
|
"@types/escape-regexp": "0.0.1",
|
||||||
|
"@types/estree": "^1.0.0",
|
||||||
"@types/gulp": "4.0.10",
|
"@types/gulp": "4.0.10",
|
||||||
"@types/gulp-rename": "2.0.1",
|
"@types/gulp-rename": "2.0.1",
|
||||||
"@types/matter-js": "0.18.2",
|
"@types/matter-js": "0.18.2",
|
||||||
|
@ -80,6 +102,7 @@
|
||||||
"@types/punycode": "2.1.0",
|
"@types/punycode": "2.1.0",
|
||||||
"@types/sanitize-html": "2.9.0",
|
"@types/sanitize-html": "2.9.0",
|
||||||
"@types/seedrandom": "3.0.5",
|
"@types/seedrandom": "3.0.5",
|
||||||
|
"@types/testing-library__jest-dom": "^5.14.5",
|
||||||
"@types/throttle-debounce": "5.0.0",
|
"@types/throttle-debounce": "5.0.0",
|
||||||
"@types/tinycolor2": "1.4.3",
|
"@types/tinycolor2": "1.4.3",
|
||||||
"@types/uuid": "9.0.1",
|
"@types/uuid": "9.0.1",
|
||||||
|
@ -89,13 +112,24 @@
|
||||||
"@typescript-eslint/parser": "5.57.0",
|
"@typescript-eslint/parser": "5.57.0",
|
||||||
"@vitest/coverage-c8": "^0.29.8",
|
"@vitest/coverage-c8": "^0.29.8",
|
||||||
"@vue/runtime-core": "3.2.47",
|
"@vue/runtime-core": "3.2.47",
|
||||||
|
"astring": "^1.8.4",
|
||||||
|
"chokidar-cli": "^3.0.0",
|
||||||
|
"chromatic": "^6.17.2",
|
||||||
"cross-env": "7.0.3",
|
"cross-env": "7.0.3",
|
||||||
"cypress": "12.9.0",
|
"cypress": "12.9.0",
|
||||||
"eslint": "8.37.0",
|
"eslint": "8.37.0",
|
||||||
"eslint-plugin-import": "2.27.5",
|
"eslint-plugin-import": "2.27.5",
|
||||||
"eslint-plugin-vue": "9.10.0",
|
"eslint-plugin-vue": "9.10.0",
|
||||||
|
"fast-glob": "^3.2.12",
|
||||||
"happy-dom": "8.9.0",
|
"happy-dom": "8.9.0",
|
||||||
|
"msw": "^1.1.0",
|
||||||
|
"msw-storybook-addon": "^1.8.0",
|
||||||
|
"prettier": "^2.8.4",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
"start-server-and-test": "2.0.0",
|
"start-server-and-test": "2.0.0",
|
||||||
|
"storybook": "7.0.0-rc.10",
|
||||||
|
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
|
||||||
"summaly": "github:misskey-dev/summaly",
|
"summaly": "github:misskey-dev/summaly",
|
||||||
"vitest": "^0.29.8",
|
"vitest": "^0.29.8",
|
||||||
"vitest-fetch-mock": "^0.2.2",
|
"vitest-fetch-mock": "^0.2.2",
|
||||||
|
|
303
packages/frontend/public/mockServiceWorker.js
Normal file
303
packages/frontend/public/mockServiceWorker.js
Normal file
|
@ -0,0 +1,303 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock Service Worker (1.1.0).
|
||||||
|
* @see https://github.com/mswjs/msw
|
||||||
|
* - Please do NOT modify this file.
|
||||||
|
* - Please do NOT serve this file on production.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const INTEGRITY_CHECKSUM = '3d6b9f06410d179a7f7404d4bf4c3c70'
|
||||||
|
const activeClientIds = new Set()
|
||||||
|
|
||||||
|
self.addEventListener('install', function () {
|
||||||
|
self.skipWaiting()
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('activate', function (event) {
|
||||||
|
event.waitUntil(self.clients.claim())
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('message', async function (event) {
|
||||||
|
const clientId = event.source.id
|
||||||
|
|
||||||
|
if (!clientId || !self.clients) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await self.clients.get(clientId)
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
})
|
||||||
|
|
||||||
|
switch (event.data) {
|
||||||
|
case 'KEEPALIVE_REQUEST': {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'KEEPALIVE_RESPONSE',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'INTEGRITY_CHECK_REQUEST': {
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||||
|
payload: INTEGRITY_CHECKSUM,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'MOCK_ACTIVATE': {
|
||||||
|
activeClientIds.add(clientId)
|
||||||
|
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'MOCKING_ENABLED',
|
||||||
|
payload: true,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'MOCK_DEACTIVATE': {
|
||||||
|
activeClientIds.delete(clientId)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'CLIENT_CLOSED': {
|
||||||
|
activeClientIds.delete(clientId)
|
||||||
|
|
||||||
|
const remainingClients = allClients.filter((client) => {
|
||||||
|
return client.id !== clientId
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unregister itself when there are no more clients
|
||||||
|
if (remainingClients.length === 0) {
|
||||||
|
self.registration.unregister()
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('fetch', function (event) {
|
||||||
|
const { request } = event
|
||||||
|
const accept = request.headers.get('accept') || ''
|
||||||
|
|
||||||
|
// Bypass server-sent events.
|
||||||
|
if (accept.includes('text/event-stream')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass navigation requests.
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening the DevTools triggers the "only-if-cached" request
|
||||||
|
// that cannot be handled by the worker. Bypass such requests.
|
||||||
|
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass all requests when there are no active clients.
|
||||||
|
// Prevents the self-unregistered worked from handling requests
|
||||||
|
// after it's been deleted (still remains active until the next reload).
|
||||||
|
if (activeClientIds.size === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique request ID.
|
||||||
|
const requestId = Math.random().toString(16).slice(2)
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
handleRequest(event, requestId).catch((error) => {
|
||||||
|
if (error.name === 'NetworkError') {
|
||||||
|
console.warn(
|
||||||
|
'[MSW] Successfully emulated a network error for the "%s %s" request.',
|
||||||
|
request.method,
|
||||||
|
request.url,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// At this point, any exception indicates an issue with the original request/response.
|
||||||
|
console.error(
|
||||||
|
`\
|
||||||
|
[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
|
||||||
|
request.method,
|
||||||
|
request.url,
|
||||||
|
`${error.name}: ${error.message}`,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleRequest(event, requestId) {
|
||||||
|
const client = await resolveMainClient(event)
|
||||||
|
const response = await getResponse(event, client, requestId)
|
||||||
|
|
||||||
|
// Send back the response clone for the "response:*" life-cycle events.
|
||||||
|
// Ensure MSW is active and ready to handle the message, otherwise
|
||||||
|
// this message will pend indefinitely.
|
||||||
|
if (client && activeClientIds.has(client.id)) {
|
||||||
|
;(async function () {
|
||||||
|
const clonedResponse = response.clone()
|
||||||
|
sendToClient(client, {
|
||||||
|
type: 'RESPONSE',
|
||||||
|
payload: {
|
||||||
|
requestId,
|
||||||
|
type: clonedResponse.type,
|
||||||
|
ok: clonedResponse.ok,
|
||||||
|
status: clonedResponse.status,
|
||||||
|
statusText: clonedResponse.statusText,
|
||||||
|
body:
|
||||||
|
clonedResponse.body === null ? null : await clonedResponse.text(),
|
||||||
|
headers: Object.fromEntries(clonedResponse.headers.entries()),
|
||||||
|
redirected: clonedResponse.redirected,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the main client for the given event.
|
||||||
|
// Client that issues a request doesn't necessarily equal the client
|
||||||
|
// that registered the worker. It's with the latter the worker should
|
||||||
|
// communicate with during the response resolving phase.
|
||||||
|
async function resolveMainClient(event) {
|
||||||
|
const client = await self.clients.get(event.clientId)
|
||||||
|
|
||||||
|
if (client?.frameType === 'top-level') {
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
const allClients = await self.clients.matchAll({
|
||||||
|
type: 'window',
|
||||||
|
})
|
||||||
|
|
||||||
|
return allClients
|
||||||
|
.filter((client) => {
|
||||||
|
// Get only those clients that are currently visible.
|
||||||
|
return client.visibilityState === 'visible'
|
||||||
|
})
|
||||||
|
.find((client) => {
|
||||||
|
// Find the client ID that's recorded in the
|
||||||
|
// set of clients that have registered the worker.
|
||||||
|
return activeClientIds.has(client.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getResponse(event, client, requestId) {
|
||||||
|
const { request } = event
|
||||||
|
const clonedRequest = request.clone()
|
||||||
|
|
||||||
|
function passthrough() {
|
||||||
|
// Clone the request because it might've been already used
|
||||||
|
// (i.e. its body has been read and sent to the client).
|
||||||
|
const headers = Object.fromEntries(clonedRequest.headers.entries())
|
||||||
|
|
||||||
|
// Remove MSW-specific request headers so the bypassed requests
|
||||||
|
// comply with the server's CORS preflight check.
|
||||||
|
// Operate with the headers as an object because request "Headers"
|
||||||
|
// are immutable.
|
||||||
|
delete headers['x-msw-bypass']
|
||||||
|
|
||||||
|
return fetch(clonedRequest, { headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass mocking when the client is not active.
|
||||||
|
if (!client) {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass initial page load requests (i.e. static assets).
|
||||||
|
// The absence of the immediate/parent client in the map of the active clients
|
||||||
|
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||||
|
// and is not ready to handle requests.
|
||||||
|
if (!activeClientIds.has(client.id)) {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass requests with the explicit bypass header.
|
||||||
|
// Such requests can be issued by "ctx.fetch()".
|
||||||
|
if (request.headers.get('x-msw-bypass') === 'true') {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify the client that a request has been intercepted.
|
||||||
|
const clientMessage = await sendToClient(client, {
|
||||||
|
type: 'REQUEST',
|
||||||
|
payload: {
|
||||||
|
id: requestId,
|
||||||
|
url: request.url,
|
||||||
|
method: request.method,
|
||||||
|
headers: Object.fromEntries(request.headers.entries()),
|
||||||
|
cache: request.cache,
|
||||||
|
mode: request.mode,
|
||||||
|
credentials: request.credentials,
|
||||||
|
destination: request.destination,
|
||||||
|
integrity: request.integrity,
|
||||||
|
redirect: request.redirect,
|
||||||
|
referrer: request.referrer,
|
||||||
|
referrerPolicy: request.referrerPolicy,
|
||||||
|
body: await request.text(),
|
||||||
|
bodyUsed: request.bodyUsed,
|
||||||
|
keepalive: request.keepalive,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
switch (clientMessage.type) {
|
||||||
|
case 'MOCK_RESPONSE': {
|
||||||
|
return respondWithMock(clientMessage.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'MOCK_NOT_FOUND': {
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'NETWORK_ERROR': {
|
||||||
|
const { name, message } = clientMessage.data
|
||||||
|
const networkError = new Error(message)
|
||||||
|
networkError.name = name
|
||||||
|
|
||||||
|
// Rejecting a "respondWith" promise emulates a network error.
|
||||||
|
throw networkError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return passthrough()
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendToClient(client, message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const channel = new MessageChannel()
|
||||||
|
|
||||||
|
channel.port1.onmessage = (event) => {
|
||||||
|
if (event.data && event.data.error) {
|
||||||
|
return reject(event.data.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(event.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
client.postMessage(message, [channel.port2])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(timeMs) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, timeMs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function respondWithMock(response) {
|
||||||
|
await sleep(response.delay)
|
||||||
|
return new Response(response.body, response)
|
||||||
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import MkAnalogClock from './MkAnalogClock.vue';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkAnalogClock,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkAnalogClock v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'fullscreen',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAnalogClock>;
|
30
packages/frontend/src/components/MkButton.stories.impl.ts
Normal file
30
packages/frontend/src/components/MkButton.stories.impl.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
/* eslint-disable import/no-default-export */
|
||||||
|
/* eslint-disable import/no-duplicates */
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import MkButton from './MkButton.vue';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkButton,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkButton v-bind="props">Text</MkButton>',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkButton>;
|
|
@ -0,0 +1,2 @@
|
||||||
|
import MkCaptcha from './MkCaptcha.vue';
|
||||||
|
void MkCaptcha;
|
|
@ -17,8 +17,8 @@ import { onMounted, onBeforeUnmount } from 'vue';
|
||||||
import MkMenu from './MkMenu.vue';
|
import MkMenu from './MkMenu.vue';
|
||||||
import { MenuItem } from './types/menu.vue';
|
import { MenuItem } from './types/menu.vue';
|
||||||
import contains from '@/scripts/contains';
|
import contains from '@/scripts/contains';
|
||||||
import * as os from '@/os';
|
|
||||||
import { defaultStore } from '@/store';
|
import { defaultStore } from '@/store';
|
||||||
|
import * as os from '@/os';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
items: MenuItem[];
|
items: MenuItem[];
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div role="menu">
|
||||||
<div
|
<div
|
||||||
ref="itemsEl" v-hotkey="keymap"
|
ref="itemsEl" v-hotkey="keymap"
|
||||||
class="_popup _shadow"
|
class="_popup _shadow"
|
||||||
|
@ -8,37 +8,37 @@
|
||||||
@contextmenu.self="e => e.preventDefault()"
|
@contextmenu.self="e => e.preventDefault()"
|
||||||
>
|
>
|
||||||
<template v-for="(item, i) in items2">
|
<template v-for="(item, i) in items2">
|
||||||
<div v-if="item === null" :class="$style.divider"></div>
|
<div v-if="item === null" role="separator" :class="$style.divider"></div>
|
||||||
<span v-else-if="item.type === 'label'" :class="[$style.label, $style.item]">
|
<span v-else-if="item.type === 'label'" role="menuitem" :class="[$style.label, $style.item]">
|
||||||
<span>{{ item.text }}</span>
|
<span>{{ item.text }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="item.type === 'pending'" :tabindex="i" :class="[$style.pending, $style.item]">
|
<span v-else-if="item.type === 'pending'" role="menuitem" :tabindex="i" :class="[$style.pending, $style.item]">
|
||||||
<span><MkEllipsis/></span>
|
<span><MkEllipsis/></span>
|
||||||
</span>
|
</span>
|
||||||
<MkA v-else-if="item.type === 'link'" :to="item.to" :tabindex="i" class="_button" :class="$style.item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
<MkA v-else-if="item.type === 'link'" role="menuitem" :to="item.to" :tabindex="i" class="_button" :class="$style.item" @click.passive="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||||
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
|
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
|
||||||
<span>{{ item.text }}</span>
|
<span>{{ item.text }}</span>
|
||||||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||||
</MkA>
|
</MkA>
|
||||||
<a v-else-if="item.type === 'a'" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button" :class="$style.item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
<a v-else-if="item.type === 'a'" role="menuitem" :href="item.href" :target="item.target" :download="item.download" :tabindex="i" class="_button" :class="$style.item" @click="close(true)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||||
<span>{{ item.text }}</span>
|
<span>{{ item.text }}</span>
|
||||||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||||
</a>
|
</a>
|
||||||
<button v-else-if="item.type === 'user'" :tabindex="i" class="_button" :class="[$style.item, { [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
<button v-else-if="item.type === 'user'" role="menuitem" :tabindex="i" class="_button" :class="[$style.item, { [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||||
<MkAvatar :user="item.user" :class="$style.avatar"/><MkUserName :user="item.user"/>
|
<MkAvatar :user="item.user" :class="$style.avatar"/><MkUserName :user="item.user"/>
|
||||||
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
|
||||||
</button>
|
</button>
|
||||||
<span v-else-if="item.type === 'switch'" :tabindex="i" :class="$style.item" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
<span v-else-if="item.type === 'switch'" role="menuitemcheckbox" :tabindex="i" :class="$style.item" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||||
<MkSwitch v-model="item.ref" :disabled="item.disabled" class="form-switch">{{ item.text }}</MkSwitch>
|
<MkSwitch v-model="item.ref" :disabled="item.disabled" class="form-switch">{{ item.text }}</MkSwitch>
|
||||||
</span>
|
</span>
|
||||||
<button v-else-if="item.type === 'parent'" :tabindex="i" class="_button" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="showChildren(item, $event)">
|
<button v-else-if="item.type === 'parent'" role="menuitem" :tabindex="i" class="_button" :class="[$style.item, $style.parent, { [$style.childShowing]: childShowingItem === item }]" @mouseenter="showChildren(item, $event)">
|
||||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||||
<span>{{ item.text }}</span>
|
<span>{{ item.text }}</span>
|
||||||
<span :class="$style.caret"><i class="ti ti-chevron-right ti-fw"></i></span>
|
<span :class="$style.caret"><i class="ti ti-chevron-right ti-fw"></i></span>
|
||||||
</button>
|
</button>
|
||||||
<button v-else :tabindex="i" class="_button" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
<button v-else :tabindex="i" class="_button" role="menuitem" :class="[$style.item, { [$style.danger]: item.danger, [$style.active]: item.active }]" :disabled="item.active" @click="clicked(item.action, $event)" @mouseenter.passive="onItemMouseEnter(item)" @mouseleave.passive="onItemMouseLeave(item)">
|
||||||
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
<i v-if="item.icon" class="ti-fw" :class="[$style.icon, item.icon]"></i>
|
||||||
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
|
<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
|
||||||
<span>{{ item.text }}</span>
|
<span>{{ item.text }}</span>
|
||||||
|
|
|
@ -150,7 +150,7 @@ function adjustTweetHeight(message: any) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const openPlayer = (): void => {
|
const openPlayer = (): void => {
|
||||||
os.popup(defineAsyncComponent(() => import('@/components/MkYoutubePlayer.vue')), {
|
os.popup(defineAsyncComponent(() => import('@/components/MkYouTubePlayer.vue')), {
|
||||||
url: requestUrl.href,
|
url: requestUrl.href,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
47
packages/frontend/src/components/global/MkA.stories.impl.ts
Normal file
47
packages/frontend/src/components/global/MkA.stories.impl.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { expect } from '@storybook/jest';
|
||||||
|
import { userEvent, within } from '@storybook/testing-library';
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import MkA from './MkA.vue';
|
||||||
|
import { tick } from '@/scripts/test-utils';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkA,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkA v-bind="props">Text</MkA>',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
const a = canvas.getByRole<HTMLAnchorElement>('link');
|
||||||
|
await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
|
||||||
|
await userEvent.click(a, { button: 2 });
|
||||||
|
await tick();
|
||||||
|
const menu = canvas.getByRole('menu');
|
||||||
|
await expect(menu).toBeInTheDocument();
|
||||||
|
await userEvent.click(a, { button: 0 });
|
||||||
|
a.blur();
|
||||||
|
await tick();
|
||||||
|
await expect(menu).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
to: '#test',
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkA>;
|
|
@ -0,0 +1,43 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import { userDetailed } from '../../../.storybook/fakes';
|
||||||
|
import MkAcct from './MkAcct.vue';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkAcct,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkAcct v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
user: {
|
||||||
|
...userDetailed,
|
||||||
|
host: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAcct>;
|
||||||
|
export const Detail = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
user: userDetailed,
|
||||||
|
detail: true,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAcct>;
|
|
@ -18,4 +18,3 @@ defineProps<{
|
||||||
|
|
||||||
const host = toUnicode(hostRaw);
|
const host = toUnicode(hostRaw);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
120
packages/frontend/src/components/global/MkAd.stories.impl.ts
Normal file
120
packages/frontend/src/components/global/MkAd.stories.impl.ts
Normal file
|
@ -0,0 +1,120 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { expect } from '@storybook/jest';
|
||||||
|
import { userEvent, within } from '@storybook/testing-library';
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import { i18n } from '@/i18n';
|
||||||
|
import MkAd from './MkAd.vue';
|
||||||
|
const common = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkAd,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkAd v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async play({ canvasElement, args }) {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
const a = canvas.getByRole<HTMLAnchorElement>('link');
|
||||||
|
await expect(a.href).toMatch(/^https?:\/\/.*#test$/);
|
||||||
|
const img = within(a).getByRole('img');
|
||||||
|
await expect(img).toBeInTheDocument();
|
||||||
|
let buttons = canvas.getAllByRole<HTMLButtonElement>('button');
|
||||||
|
await expect(buttons).toHaveLength(1);
|
||||||
|
const i = buttons[0];
|
||||||
|
await expect(i).toBeInTheDocument();
|
||||||
|
await userEvent.click(i);
|
||||||
|
await expect(a).not.toBeInTheDocument();
|
||||||
|
await expect(i).not.toBeInTheDocument();
|
||||||
|
buttons = canvas.getAllByRole<HTMLButtonElement>('button');
|
||||||
|
await expect(buttons).toHaveLength(args.__hasReduce ? 2 : 1);
|
||||||
|
const reduce = args.__hasReduce ? buttons[0] : null;
|
||||||
|
const back = buttons[args.__hasReduce ? 1 : 0];
|
||||||
|
if (reduce) {
|
||||||
|
await expect(reduce).toBeInTheDocument();
|
||||||
|
await expect(reduce).toHaveTextContent(i18n.ts._ad.reduceFrequencyOfThisAd);
|
||||||
|
}
|
||||||
|
await expect(back).toBeInTheDocument();
|
||||||
|
await expect(back).toHaveTextContent(i18n.ts._ad.back);
|
||||||
|
await userEvent.click(back);
|
||||||
|
if (reduce) {
|
||||||
|
await expect(reduce).not.toBeInTheDocument();
|
||||||
|
}
|
||||||
|
await expect(back).not.toBeInTheDocument();
|
||||||
|
const aAgain = canvas.getByRole<HTMLAnchorElement>('link');
|
||||||
|
await expect(aAgain).toBeInTheDocument();
|
||||||
|
const imgAgain = within(aAgain).getByRole('img');
|
||||||
|
await expect(imgAgain).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
prefer: [],
|
||||||
|
specify: {
|
||||||
|
id: 'someadid',
|
||||||
|
radio: 1,
|
||||||
|
url: '#test',
|
||||||
|
},
|
||||||
|
__hasReduce: true,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAd>;
|
||||||
|
export const Square = {
|
||||||
|
...common,
|
||||||
|
args: {
|
||||||
|
...common.args,
|
||||||
|
specify: {
|
||||||
|
...common.args.specify,
|
||||||
|
place: 'square',
|
||||||
|
imageUrl:
|
||||||
|
'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAd>;
|
||||||
|
export const Horizontal = {
|
||||||
|
...common,
|
||||||
|
args: {
|
||||||
|
...common.args,
|
||||||
|
specify: {
|
||||||
|
...common.args.specify,
|
||||||
|
place: 'horizontal',
|
||||||
|
imageUrl:
|
||||||
|
'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAd>;
|
||||||
|
export const HorizontalBig = {
|
||||||
|
...common,
|
||||||
|
args: {
|
||||||
|
...common.args,
|
||||||
|
specify: {
|
||||||
|
...common.args.specify,
|
||||||
|
place: 'horizontal-big',
|
||||||
|
imageUrl:
|
||||||
|
'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/fedi.jpg?raw=true',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAd>;
|
||||||
|
export const ZeroRatio = {
|
||||||
|
...Square,
|
||||||
|
args: {
|
||||||
|
...Square.args,
|
||||||
|
specify: {
|
||||||
|
...Square.args.specify,
|
||||||
|
ratio: 0,
|
||||||
|
},
|
||||||
|
__hasReduce: false,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAd>;
|
|
@ -20,13 +20,13 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
import { i18n } from '@/i18n';
|
||||||
import { instance } from '@/instance';
|
import { instance } from '@/instance';
|
||||||
import { host } from '@/config';
|
import { host } from '@/config';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import { defaultStore } from '@/store';
|
import { defaultStore } from '@/store';
|
||||||
import * as os from '@/os';
|
import * as os from '@/os';
|
||||||
import { $i } from '@/account';
|
import { $i } from '@/account';
|
||||||
import { i18n } from '@/i18n';
|
|
||||||
|
|
||||||
type Ad = (typeof instance)['ads'][number];
|
type Ad = (typeof instance)['ads'][number];
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import { userDetailed } from '../../../.storybook/fakes';
|
||||||
|
import MkAvatar from './MkAvatar.vue';
|
||||||
|
const common = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkAvatar,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkAvatar v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
user: userDetailed,
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story, context) => ({
|
||||||
|
// eslint-disable-next-line quotes
|
||||||
|
template: `<div :style="{ display: 'grid', width: '${context.args.size}px', height: '${context.args.size}px' }"><story/></div>`,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAvatar>;
|
||||||
|
export const ProfilePage = {
|
||||||
|
...common,
|
||||||
|
args: {
|
||||||
|
...common.args,
|
||||||
|
size: 120,
|
||||||
|
indicator: true,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAvatar>;
|
||||||
|
export const ProfilePageCat = {
|
||||||
|
...ProfilePage,
|
||||||
|
args: {
|
||||||
|
...ProfilePage.args,
|
||||||
|
user: {
|
||||||
|
...userDetailed,
|
||||||
|
isCat: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
...ProfilePage.parameters,
|
||||||
|
chromatic: {
|
||||||
|
/* Your story couldn’t be captured because it exceeds our 25,000,000px limit. Its dimensions are 5,504,893x5,504,892px. Possible ways to resolve:
|
||||||
|
* * Separate pages into components
|
||||||
|
* * Minimize the number of very large elements in a story
|
||||||
|
*/
|
||||||
|
disableSnapshot: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkAvatar>;
|
|
@ -148,6 +148,7 @@ watch(() => props.user.avatarBlurhash, () => {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding: 50%;
|
padding: 50%;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
&.mask {
|
&.mask {
|
||||||
-webkit-mask:
|
-webkit-mask:
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import MkCustomEmoji from './MkCustomEmoji.vue';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkCustomEmoji,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkCustomEmoji v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
name: 'mi',
|
||||||
|
url: 'https://github.com/misskey-dev/misskey/blob/master/packages/frontend/assets/about-icon.png?raw=true',
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkCustomEmoji>;
|
||||||
|
export const Normal = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
normal: true,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkCustomEmoji>;
|
||||||
|
export const Missing = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
name: Default.args.name,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkCustomEmoji>;
|
|
@ -0,0 +1,32 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import isChromatic from 'chromatic/isChromatic';
|
||||||
|
import MkEllipsis from './MkEllipsis.vue';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkEllipsis,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkEllipsis v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
static: isChromatic(),
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkEllipsis>;
|
|
@ -1,9 +1,19 @@
|
||||||
<template>
|
<template>
|
||||||
<span :class="$style.root">
|
<span :class="[$style.root, { [$style.static]: static }]">
|
||||||
<span :class="$style.dot">.</span><span :class="$style.dot">.</span><span :class="$style.dot">.</span>
|
<span :class="$style.dot">.</span><span :class="$style.dot">.</span><span :class="$style.dot">.</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { } from 'vue';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
static?: boolean;
|
||||||
|
}>(), {
|
||||||
|
static: false,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
@keyframes ellipsis {
|
@keyframes ellipsis {
|
||||||
0%, 80%, 100% {
|
0%, 80%, 100% {
|
||||||
|
@ -15,7 +25,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
|
&.static > .dot {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot {
|
.dot {
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import MkEmoji from './MkEmoji.vue';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkEmoji,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkEmoji v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
emoji: '❤',
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkEmoji>;
|
|
@ -0,0 +1,5 @@
|
||||||
|
export const argTypes = {
|
||||||
|
retry: {
|
||||||
|
action: 'retry',
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,60 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import isChromatic from 'chromatic/isChromatic';
|
||||||
|
import MkLoading from './MkLoading.vue';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkLoading,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkLoading v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
static: isChromatic(),
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkLoading>;
|
||||||
|
export const Inline = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkLoading>;
|
||||||
|
export const Colored = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
colored: true,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkLoading>;
|
||||||
|
export const Mini = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
mini: true,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkLoading>;
|
||||||
|
export const Em = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
em: true,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkLoading>;
|
|
@ -6,7 +6,7 @@
|
||||||
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
|
<circle cx="64" cy="64" r="64" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
<svg :class="[$style.spinner, $style.fg]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg">
|
<svg :class="[$style.spinner, $style.fg, { [$style.static]: static }]" viewBox="0 0 168 168" xmlns="http://www.w3.org/2000/svg">
|
||||||
<g transform="matrix(1.125,0,0,1.125,12,12)">
|
<g transform="matrix(1.125,0,0,1.125,12,12)">
|
||||||
<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
|
<path d="M128,64C128,28.654 99.346,0 64,0C99.346,0 128,28.654 128,64Z" style="fill:none;stroke:currentColor;stroke-width:21.33px;"/>
|
||||||
</g>
|
</g>
|
||||||
|
@ -19,11 +19,13 @@
|
||||||
import { } from 'vue';
|
import { } from 'vue';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
|
static?: boolean;
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
colored?: boolean;
|
colored?: boolean;
|
||||||
mini?: boolean;
|
mini?: boolean;
|
||||||
em?: boolean;
|
em?: boolean;
|
||||||
}>(), {
|
}>(), {
|
||||||
|
static: false,
|
||||||
inline: false,
|
inline: false,
|
||||||
colored: true,
|
colored: true,
|
||||||
mini: false,
|
mini: false,
|
||||||
|
@ -97,5 +99,9 @@ const props = withDefaults(defineProps<{
|
||||||
|
|
||||||
.fg {
|
.fg {
|
||||||
animation: spinner 0.5s linear infinite;
|
animation: spinner 0.5s linear infinite;
|
||||||
|
|
||||||
|
&.static {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -0,0 +1,74 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import MkMisskeyFlavoredMarkdown from './MkMisskeyFlavoredMarkdown.vue';
|
||||||
|
import { within } from '@storybook/testing-library';
|
||||||
|
import { expect } from '@storybook/jest';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkMisskeyFlavoredMarkdown,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkMisskeyFlavoredMarkdown v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async play({ canvasElement, args }) {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
if (args.plain) {
|
||||||
|
const aiHelloMiskist = canvas.getByText('@ai *Hello*, #Miskist!');
|
||||||
|
await expect(aiHelloMiskist).toBeInTheDocument();
|
||||||
|
} else {
|
||||||
|
const ai = canvas.getByText('@ai');
|
||||||
|
await expect(ai).toBeInTheDocument();
|
||||||
|
await expect(ai.closest('a')).toHaveAttribute('href', '/@ai');
|
||||||
|
const hello = canvas.getByText('Hello');
|
||||||
|
await expect(hello).toBeInTheDocument();
|
||||||
|
await expect(hello.style.fontStyle).toBe('oblique');
|
||||||
|
const miskist = canvas.getByText('#Miskist');
|
||||||
|
await expect(miskist).toBeInTheDocument();
|
||||||
|
await expect(miskist).toHaveAttribute('href', args.isNote ?? true ? '/tags/Miskist' : '/user-tags/Miskist');
|
||||||
|
}
|
||||||
|
const heart = canvas.getByAltText('❤');
|
||||||
|
await expect(heart).toBeInTheDocument();
|
||||||
|
await expect(heart).toHaveAttribute('src', '/twemoji/2764.svg');
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
text: '@ai *Hello*, #Miskist! ❤',
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>;
|
||||||
|
export const Plain = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
plain: true,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>;
|
||||||
|
export const Nowrap = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
nowrap: true,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>;
|
||||||
|
export const IsNotNote = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
isNote: false,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkMisskeyFlavoredMarkdown>;
|
|
@ -0,0 +1,98 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import MkPageHeader from './MkPageHeader.vue';
|
||||||
|
export const Empty = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkPageHeader,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkPageHeader v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
static: true,
|
||||||
|
tabs: [],
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
chromatic: {
|
||||||
|
/* This component has animations that are implemented with JavaScript. So it's unstable to take a snapshot. */
|
||||||
|
disableSnapshot: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkPageHeader>;
|
||||||
|
export const OneTab = {
|
||||||
|
...Empty,
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
tab: 'sometabkey',
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
key: 'sometabkey',
|
||||||
|
title: 'Some Tab Title',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkPageHeader>;
|
||||||
|
export const Icon = {
|
||||||
|
...OneTab,
|
||||||
|
args: {
|
||||||
|
...OneTab.args,
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
...OneTab.args.tabs[0],
|
||||||
|
icon: 'ti ti-home',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkPageHeader>;
|
||||||
|
export const IconOnly = {
|
||||||
|
...Icon,
|
||||||
|
args: {
|
||||||
|
...Icon.args,
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
...Icon.args.tabs[0],
|
||||||
|
title: undefined,
|
||||||
|
iconOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkPageHeader>;
|
||||||
|
export const SomeTabs = {
|
||||||
|
...Empty,
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
tab: 'princess',
|
||||||
|
tabs: [
|
||||||
|
{
|
||||||
|
key: 'princess',
|
||||||
|
title: 'Princess',
|
||||||
|
icon: 'ti ti-crown',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'fairy',
|
||||||
|
title: 'Fairy',
|
||||||
|
icon: 'ti ti-snowflake',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'angel',
|
||||||
|
title: 'Angel',
|
||||||
|
icon: 'ti ti-feather',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkPageHeader>;
|
|
@ -0,0 +1,3 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import MkPageHeader_tabs from './MkPageHeader.tabs.vue';
|
||||||
|
void MkPageHeader_tabs;
|
|
@ -33,14 +33,18 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export type Tab = {
|
export type Tab = {
|
||||||
key: string;
|
key: string;
|
||||||
title: string;
|
|
||||||
icon?: string;
|
|
||||||
iconOnly?: boolean;
|
|
||||||
onClick?: (ev: MouseEvent) => void;
|
onClick?: (ev: MouseEvent) => void;
|
||||||
} & {
|
} & (
|
||||||
iconOnly: true;
|
| {
|
||||||
iccn: string;
|
iconOnly?: false;
|
||||||
};
|
title: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
iconOnly: true;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import MkStickyContainer from './MkStickyContainer.vue';
|
||||||
|
void MkStickyContainer;
|
312
packages/frontend/src/components/global/MkTime.stories.impl.ts
Normal file
312
packages/frontend/src/components/global/MkTime.stories.impl.ts
Normal file
|
@ -0,0 +1,312 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { expect } from '@storybook/jest';
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import MkTime from './MkTime.vue';
|
||||||
|
import { i18n } from '@/i18n';
|
||||||
|
import { dateTimeFormat } from '@/scripts/intl-const';
|
||||||
|
const now = new Date('2023-04-01T00:00:00.000Z');
|
||||||
|
const future = new Date(8640000000000000);
|
||||||
|
const oneHourAgo = new Date(now.getTime() - 3600000);
|
||||||
|
const oneDayAgo = new Date(now.getTime() - 86400000);
|
||||||
|
const oneWeekAgo = new Date(now.getTime() - 604800000);
|
||||||
|
const oneMonthAgo = new Date(now.getTime() - 2592000000);
|
||||||
|
const oneYearAgo = new Date(now.getTime() - 31536000000);
|
||||||
|
export const Empty = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkTime,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkTime v-bind="props" />',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(i18n.ts._ago.invalid);
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const RelativeFuture = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(i18n.ts._ago.future);
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: future,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const AbsoluteFuture = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement, args }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: future,
|
||||||
|
mode: 'absolute',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const DetailFuture = {
|
||||||
|
...Empty,
|
||||||
|
async play(context) {
|
||||||
|
await AbsoluteFuture.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||||
|
await RelativeFuture.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(')');
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: future,
|
||||||
|
mode: 'detail',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const RelativeNow = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(i18n.ts._ago.justNow);
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: now,
|
||||||
|
origin: now,
|
||||||
|
mode: 'relative',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const AbsoluteNow = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement, args }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: now,
|
||||||
|
origin: now,
|
||||||
|
mode: 'absolute',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const DetailNow = {
|
||||||
|
...Empty,
|
||||||
|
async play(context) {
|
||||||
|
await AbsoluteNow.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||||
|
await RelativeNow.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(')');
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: now,
|
||||||
|
origin: now,
|
||||||
|
mode: 'detail',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const RelativeOneHourAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.hoursAgo', { n: 1 }));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneHourAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'relative',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const AbsoluteOneHourAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement, args }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneHourAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'absolute',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const DetailOneHourAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play(context) {
|
||||||
|
await AbsoluteOneHourAgo.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||||
|
await RelativeOneHourAgo.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(')');
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneHourAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'detail',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const RelativeOneDayAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.daysAgo', { n: 1 }));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneDayAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'relative',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const AbsoluteOneDayAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement, args }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneDayAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'absolute',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const DetailOneDayAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play(context) {
|
||||||
|
await AbsoluteOneDayAgo.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||||
|
await RelativeOneDayAgo.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(')');
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneDayAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'detail',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const RelativeOneWeekAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.weeksAgo', { n: 1 }));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneWeekAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'relative',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const AbsoluteOneWeekAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement, args }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneWeekAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'absolute',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const DetailOneWeekAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play(context) {
|
||||||
|
await AbsoluteOneWeekAgo.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||||
|
await RelativeOneWeekAgo.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(')');
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneWeekAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'detail',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const RelativeOneMonthAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.monthsAgo', { n: 1 }));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneMonthAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'relative',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const AbsoluteOneMonthAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement, args }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneMonthAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'absolute',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const DetailOneMonthAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play(context) {
|
||||||
|
await AbsoluteOneMonthAgo.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||||
|
await RelativeOneMonthAgo.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(')');
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneMonthAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'detail',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const RelativeOneYearAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(i18n.t('_ago.yearsAgo', { n: 1 }));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneYearAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'relative',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const AbsoluteOneYearAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play({ canvasElement, args }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(dateTimeFormat.format(args.time));
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneYearAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'absolute',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
||||||
|
export const DetailOneYearAgo = {
|
||||||
|
...Empty,
|
||||||
|
async play(context) {
|
||||||
|
await AbsoluteOneYearAgo.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(' (');
|
||||||
|
await RelativeOneYearAgo.play(context);
|
||||||
|
await expect(context.canvasElement).toHaveTextContent(')');
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Empty.args,
|
||||||
|
time: oneYearAgo,
|
||||||
|
origin: now,
|
||||||
|
mode: 'detail',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkTime>;
|
|
@ -14,8 +14,10 @@ import { dateTimeFormat } from '@/scripts/intl-const';
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
time: Date | string | number | null;
|
time: Date | string | number | null;
|
||||||
|
origin?: Date | null;
|
||||||
mode?: 'relative' | 'absolute' | 'detail';
|
mode?: 'relative' | 'absolute' | 'detail';
|
||||||
}>(), {
|
}>(), {
|
||||||
|
origin: null,
|
||||||
mode: 'relative',
|
mode: 'relative',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -25,7 +27,7 @@ const _time = props.time == null ? NaN :
|
||||||
const invalid = Number.isNaN(_time);
|
const invalid = Number.isNaN(_time);
|
||||||
const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid;
|
const absolute = !invalid ? dateTimeFormat.format(_time) : i18n.ts._ago.invalid;
|
||||||
|
|
||||||
let now = $ref((new Date()).getTime());
|
let now = $ref((props.origin ?? new Date()).getTime());
|
||||||
const relative = $computed<string>(() => {
|
const relative = $computed<string>(() => {
|
||||||
if (props.mode === 'absolute') return ''; // absoluteではrelativeを使わないので計算しない
|
if (props.mode === 'absolute') return ''; // absoluteではrelativeを使わないので計算しない
|
||||||
if (invalid) return i18n.ts._ago.invalid;
|
if (invalid) return i18n.ts._ago.invalid;
|
||||||
|
@ -46,7 +48,7 @@ const relative = $computed<string>(() => {
|
||||||
let tickId: number;
|
let tickId: number;
|
||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
now = (new Date()).getTime();
|
now = props.origin ?? (new Date()).getTime();
|
||||||
const ago = (now - _time) / 1000/*ms*/;
|
const ago = (now - _time) / 1000/*ms*/;
|
||||||
const next = ago < 60 ? 10000 : ago < 3600 ? 60000 : 180000;
|
const next = ago < 60 ? 10000 : ago < 3600 ? 60000 : 180000;
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,77 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { expect } from '@storybook/jest';
|
||||||
|
import { userEvent, within } from '@storybook/testing-library';
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import { rest } from 'msw';
|
||||||
|
import { commonHandlers } from '../../../.storybook/mocks';
|
||||||
|
import MkUrl from './MkUrl.vue';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkUrl,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkUrl v-bind="props">Text</MkUrl>',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
const a = canvas.getByRole<HTMLAnchorElement>('link');
|
||||||
|
await expect(a).toHaveAttribute('href', 'https://misskey-hub.net/');
|
||||||
|
await userEvent.hover(a);
|
||||||
|
/*
|
||||||
|
await tick(); // FIXME: wait for network request
|
||||||
|
const anchors = canvas.getAllByRole<HTMLAnchorElement>('link');
|
||||||
|
const popup = anchors.find(anchor => anchor !== a)!; // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
||||||
|
await expect(popup).toBeInTheDocument();
|
||||||
|
await expect(popup).toHaveAttribute('href', 'https://misskey-hub.net/');
|
||||||
|
await expect(popup).toHaveTextContent('Misskey Hub');
|
||||||
|
await expect(popup).toHaveTextContent('Misskeyはオープンソースの分散型ソーシャルネットワーキングプラットフォームです。');
|
||||||
|
await expect(popup).toHaveTextContent('misskey-hub.net');
|
||||||
|
const icon = within(popup).getByRole('img');
|
||||||
|
await expect(icon).toBeInTheDocument();
|
||||||
|
await expect(icon).toHaveAttribute('src', 'https://misskey-hub.net/favicon.ico');
|
||||||
|
*/
|
||||||
|
await userEvent.unhover(a);
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
url: 'https://misskey-hub.net/',
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
msw: {
|
||||||
|
handlers: [
|
||||||
|
...commonHandlers,
|
||||||
|
rest.get('/url', (req, res, ctx) => {
|
||||||
|
return res(ctx.json({
|
||||||
|
title: 'Misskey Hub',
|
||||||
|
icon: 'https://misskey-hub.net/favicon.ico',
|
||||||
|
description: 'Misskeyはオープンソースの分散型ソーシャルネットワーキングプラットフォームです。',
|
||||||
|
thumbnail: null,
|
||||||
|
player: {
|
||||||
|
url: null,
|
||||||
|
width: null,
|
||||||
|
height: null,
|
||||||
|
allow: [],
|
||||||
|
},
|
||||||
|
sitename: 'misskey-hub.net',
|
||||||
|
sensitive: false,
|
||||||
|
url: 'https://misskey-hub.net/',
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkUrl>;
|
|
@ -0,0 +1,57 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import { expect } from '@storybook/jest';
|
||||||
|
import { userEvent, within } from '@storybook/testing-library';
|
||||||
|
import { StoryObj } from '@storybook/vue3';
|
||||||
|
import { userDetailed } from '../../../.storybook/fakes';
|
||||||
|
import MkUserName from './MkUserName.vue';
|
||||||
|
export const Default = {
|
||||||
|
render(args) {
|
||||||
|
return {
|
||||||
|
components: {
|
||||||
|
MkUserName,
|
||||||
|
},
|
||||||
|
setup() {
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
props() {
|
||||||
|
return {
|
||||||
|
...this.args,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
template: '<MkUserName v-bind="props"/>',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(userDetailed.name);
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
user: userDetailed,
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkUserName>;
|
||||||
|
export const Anonymous = {
|
||||||
|
...Default,
|
||||||
|
async play({ canvasElement }) {
|
||||||
|
await expect(canvasElement).toHaveTextContent(userDetailed.username);
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
user: {
|
||||||
|
...userDetailed,
|
||||||
|
name: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkUserName>;
|
||||||
|
export const Wrap = {
|
||||||
|
...Default,
|
||||||
|
args: {
|
||||||
|
...Default.args,
|
||||||
|
nowrap: false,
|
||||||
|
},
|
||||||
|
} satisfies StoryObj<typeof MkUserName>;
|
|
@ -0,0 +1,3 @@
|
||||||
|
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||||
|
import RouterView from './RouterView.vue';
|
||||||
|
void RouterView;
|
12
packages/frontend/src/index.mdx
Normal file
12
packages/frontend/src/index.mdx
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { Meta } from '@storybook/blocks'
|
||||||
|
|
||||||
|
<Meta title="index" />
|
||||||
|
|
||||||
|
# Welcome to Misskey Storybook
|
||||||
|
|
||||||
|
This project uses [Storybook](https://storybook.js.org/) to develop and document components.
|
||||||
|
You can find more information about the usage of Storybook in this project in the CONTRIBUTING.md file placed in the root of this repository.
|
||||||
|
|
||||||
|
The Misskey Storybook is under development and not all components are documented yet.
|
||||||
|
Contributions are welcome! Please refer to [#10336](https://github.com/misskey-dev/misskey/issues/10336) for more information.
|
||||||
|
Thank you for your support!
|
|
@ -77,7 +77,10 @@ async function renderChart() {
|
||||||
barPercentage: 0.7,
|
barPercentage: 0.7,
|
||||||
categoryPercentage: 0.7,
|
categoryPercentage: 0.7,
|
||||||
fill: true,
|
fill: true,
|
||||||
} satisfies ChartDataset, extra);
|
/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
|
||||||
|
} satisfies ChartData, extra);
|
||||||
|
*/
|
||||||
|
}, extra);
|
||||||
}
|
}
|
||||||
|
|
||||||
chartInstance = new Chart(chartEl, {
|
chartInstance = new Chart(chartEl, {
|
||||||
|
|
|
@ -113,6 +113,9 @@ async function renderChart() {
|
||||||
const a = c.chart.chartArea ?? {};
|
const a = c.chart.chartArea ?? {};
|
||||||
return (a.bottom - a.top) / 7 - marginEachCell;
|
return (a.bottom - a.top) / 7 - marginEachCell;
|
||||||
},
|
},
|
||||||
|
/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
|
||||||
|
}] satisfies ChartData[],
|
||||||
|
*/
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
|
|
|
@ -76,7 +76,10 @@ async function renderChart() {
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
barPercentage: 0.9,
|
barPercentage: 0.9,
|
||||||
fill: true,
|
fill: true,
|
||||||
} satisfies ChartDataset, extra);
|
/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
|
||||||
|
} satisfies ChartData, extra);
|
||||||
|
*/
|
||||||
|
}, extra);
|
||||||
}
|
}
|
||||||
|
|
||||||
chartInstance = new Chart(chartEl, {
|
chartInstance = new Chart(chartEl, {
|
||||||
|
|
|
@ -77,7 +77,10 @@ async function renderChart() {
|
||||||
barPercentage: 0.7,
|
barPercentage: 0.7,
|
||||||
categoryPercentage: 0.7,
|
categoryPercentage: 0.7,
|
||||||
fill: true,
|
fill: true,
|
||||||
} satisfies ChartDataset, extra);
|
/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
|
||||||
|
} satisfies ChartData, extra);
|
||||||
|
*/
|
||||||
|
}, extra);
|
||||||
}
|
}
|
||||||
|
|
||||||
chartInstance = new Chart(chartEl, {
|
chartInstance = new Chart(chartEl, {
|
||||||
|
|
|
@ -443,11 +443,14 @@ export const ACHIEVEMENT_BADGES = {
|
||||||
bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
|
bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
|
||||||
frame: 'bronze',
|
frame: 'bronze',
|
||||||
},
|
},
|
||||||
|
/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
|
||||||
} as const satisfies Record<typeof ACHIEVEMENT_TYPES[number], {
|
} as const satisfies Record<typeof ACHIEVEMENT_TYPES[number], {
|
||||||
img: string;
|
img: string;
|
||||||
bg: string | null;
|
bg: string | null;
|
||||||
frame: 'bronze' | 'silver' | 'gold' | 'platinum';
|
frame: 'bronze' | 'silver' | 'gold' | 'platinum';
|
||||||
}>;
|
}>;
|
||||||
|
*/
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const claimedAchievements: typeof ACHIEVEMENT_TYPES[number][] = ($i && $i.achievements) ? $i.achievements.map(x => x.name) : [];
|
export const claimedAchievements: typeof ACHIEVEMENT_TYPES[number][] = ($i && $i.achievements) ? $i.achievements.map(x => x.name) : [];
|
||||||
|
|
||||||
|
|
6
packages/frontend/src/scripts/test-utils.ts
Normal file
6
packages/frontend/src/scripts/test-utils.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
/// <reference types="@testing-library/jest-dom"/>
|
||||||
|
|
||||||
|
export async function tick(): Promise<void> {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
await new Promise((globalThis.requestIdleCallback ?? setTimeout) as never);
|
||||||
|
}
|
|
@ -43,5 +43,8 @@
|
||||||
".eslintrc.js",
|
".eslintrc.js",
|
||||||
"./**/*.ts",
|
"./**/*.ts",
|
||||||
"./**/*.vue"
|
"./**/*.vue"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
".storybook/**/*",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import pluginVue from '@vitejs/plugin-vue';
|
import pluginVue from '@vitejs/plugin-vue';
|
||||||
import { defineConfig } from 'vite';
|
import { type UserConfig, defineConfig } from 'vite';
|
||||||
import { configDefaults as vitestConfigDefaults } from 'vitest/config';
|
|
||||||
|
|
||||||
import locales from '../../locales';
|
import locales from '../../locales';
|
||||||
import meta from '../../package.json';
|
import meta from '../../package.json';
|
||||||
|
@ -38,7 +37,7 @@ function toBase62(n: number): string {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig(({ command, mode }) => {
|
export function getConfig(): UserConfig {
|
||||||
return {
|
return {
|
||||||
base: '/vite/',
|
base: '/vite/',
|
||||||
|
|
||||||
|
@ -62,7 +61,7 @@ export default defineConfig(({ command, mode }) => {
|
||||||
|
|
||||||
css: {
|
css: {
|
||||||
modules: {
|
modules: {
|
||||||
generateScopedName: (name, filename, css) => {
|
generateScopedName(name, filename, _css): string {
|
||||||
const id = (path.relative(__dirname, filename.split('?')[0]) + '-' + name).replace(/[\\\/\.\?&=]/g, '-').replace(/(src-|vue-)/g, '');
|
const id = (path.relative(__dirname, filename.split('?')[0]) + '-' + name).replace(/[\\\/\.\?&=]/g, '-').replace(/(src-|vue-)/g, '');
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
return 'x' + toBase62(hash(id)).substring(0, 4);
|
return 'x' + toBase62(hash(id)).substring(0, 4);
|
||||||
|
@ -132,4 +131,8 @@ export default defineConfig(({ command, mode }) => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
}
|
||||||
|
|
||||||
|
const config = defineConfig(({ command, mode }) => getConfig());
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|
|
@ -21,11 +21,11 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@microsoft/api-extractor": "7.34.4",
|
"@microsoft/api-extractor": "7.34.4",
|
||||||
|
"@swc/jest": "0.2.24",
|
||||||
"@types/jest": "29.5.0",
|
"@types/jest": "29.5.0",
|
||||||
"@types/node": "18.15.11",
|
"@types/node": "18.15.11",
|
||||||
"@typescript-eslint/eslint-plugin": "5.57.0",
|
"@typescript-eslint/eslint-plugin": "5.57.0",
|
||||||
"@typescript-eslint/parser": "5.57.0",
|
"@typescript-eslint/parser": "5.57.0",
|
||||||
"@swc/jest": "0.2.24",
|
|
||||||
"eslint": "8.37.0",
|
"eslint": "8.37.0",
|
||||||
"jest": "^29.5.0",
|
"jest": "^29.5.0",
|
||||||
"jest-fetch-mock": "^3.0.3",
|
"jest-fetch-mock": "^3.0.3",
|
||||||
|
|
5580
pnpm-lock.yaml
5580
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue