* 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
 | 
			
		||||
ormconfig.json
 | 
			
		||||
temp
 | 
			
		||||
/packages/frontend/src/**/*.stories.ts
 | 
			
		||||
 | 
			
		||||
# blender backups
 | 
			
		||||
*.blend1
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										110
									
								
								CONTRIBUTING.md
									
										
									
									
									
								
							
							
						
						
									
										110
									
								
								CONTRIBUTING.md
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -203,6 +203,116 @@ niraxは、Misskeyで使用しているオリジナルのフロントエンド
 | 
			
		|||
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
 | 
			
		||||
### 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": {
 | 
			
		||||
		"watch": "vite",
 | 
			
		||||
		"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-and-coverage": "vitest --run --coverage",
 | 
			
		||||
		"typecheck": "vue-tsc --noEmit",
 | 
			
		||||
| 
						 | 
				
			
			@ -71,8 +74,27 @@
 | 
			
		|||
		"vuedraggable": "next"
 | 
			
		||||
	},
 | 
			
		||||
	"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",
 | 
			
		||||
		"@types/escape-regexp": "0.0.1",
 | 
			
		||||
		"@types/estree": "^1.0.0",
 | 
			
		||||
		"@types/gulp": "4.0.10",
 | 
			
		||||
		"@types/gulp-rename": "2.0.1",
 | 
			
		||||
		"@types/matter-js": "0.18.2",
 | 
			
		||||
| 
						 | 
				
			
			@ -80,6 +102,7 @@
 | 
			
		|||
		"@types/punycode": "2.1.0",
 | 
			
		||||
		"@types/sanitize-html": "2.9.0",
 | 
			
		||||
		"@types/seedrandom": "3.0.5",
 | 
			
		||||
		"@types/testing-library__jest-dom": "^5.14.5",
 | 
			
		||||
		"@types/throttle-debounce": "5.0.0",
 | 
			
		||||
		"@types/tinycolor2": "1.4.3",
 | 
			
		||||
		"@types/uuid": "9.0.1",
 | 
			
		||||
| 
						 | 
				
			
			@ -89,13 +112,24 @@
 | 
			
		|||
		"@typescript-eslint/parser": "5.57.0",
 | 
			
		||||
		"@vitest/coverage-c8": "^0.29.8",
 | 
			
		||||
		"@vue/runtime-core": "3.2.47",
 | 
			
		||||
		"astring": "^1.8.4",
 | 
			
		||||
		"chokidar-cli": "^3.0.0",
 | 
			
		||||
		"chromatic": "^6.17.2",
 | 
			
		||||
		"cross-env": "7.0.3",
 | 
			
		||||
		"cypress": "12.9.0",
 | 
			
		||||
		"eslint": "8.37.0",
 | 
			
		||||
		"eslint-plugin-import": "2.27.5",
 | 
			
		||||
		"eslint-plugin-vue": "9.10.0",
 | 
			
		||||
		"fast-glob": "^3.2.12",
 | 
			
		||||
		"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",
 | 
			
		||||
		"storybook": "7.0.0-rc.10",
 | 
			
		||||
		"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
 | 
			
		||||
		"summaly": "github:misskey-dev/summaly",
 | 
			
		||||
		"vitest": "^0.29.8",
 | 
			
		||||
		"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 { MenuItem } from './types/menu.vue';
 | 
			
		||||
import contains from '@/scripts/contains';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { defaultStore } from '@/store';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
	items: MenuItem[];
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
<template>
 | 
			
		||||
<div>
 | 
			
		||||
<div role="menu">
 | 
			
		||||
	<div
 | 
			
		||||
		ref="itemsEl" v-hotkey="keymap"
 | 
			
		||||
		class="_popup _shadow"
 | 
			
		||||
| 
						 | 
				
			
			@ -8,37 +8,37 @@
 | 
			
		|||
		@contextmenu.self="e => e.preventDefault()"
 | 
			
		||||
	>
 | 
			
		||||
		<template v-for="(item, i) in items2">
 | 
			
		||||
			<div v-if="item === null" :class="$style.divider"></div>
 | 
			
		||||
			<span v-else-if="item.type === 'label'" :class="[$style.label, $style.item]">
 | 
			
		||||
			<div v-if="item === null" role="separator" :class="$style.divider"></div>
 | 
			
		||||
			<span v-else-if="item.type === 'label'" role="menuitem" :class="[$style.label, $style.item]">
 | 
			
		||||
				<span>{{ item.text }}</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>
 | 
			
		||||
			<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>
 | 
			
		||||
				<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
 | 
			
		||||
				<span>{{ item.text }}</span>
 | 
			
		||||
				<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
 | 
			
		||||
			</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>
 | 
			
		||||
				<span>{{ item.text }}</span>
 | 
			
		||||
				<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
 | 
			
		||||
			</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"/>
 | 
			
		||||
				<span v-if="item.indicate" :class="$style.indicator"><i class="_indicatorCircle"></i></span>
 | 
			
		||||
			</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>
 | 
			
		||||
			</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>
 | 
			
		||||
				<span>{{ item.text }}</span>
 | 
			
		||||
				<span :class="$style.caret"><i class="ti ti-chevron-right ti-fw"></i></span>
 | 
			
		||||
			</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>
 | 
			
		||||
				<MkAvatar v-if="item.avatar" :user="item.avatar" :class="$style.avatar"/>
 | 
			
		||||
				<span>{{ item.text }}</span>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -150,7 +150,7 @@ function adjustTweetHeight(message: any) {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
const openPlayer = (): void => {
 | 
			
		||||
	os.popup(defineAsyncComponent(() => import('@/components/MkYoutubePlayer.vue')), {
 | 
			
		||||
	os.popup(defineAsyncComponent(() => import('@/components/MkYouTubePlayer.vue')), {
 | 
			
		||||
		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);
 | 
			
		||||
</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>
 | 
			
		||||
import { ref } from 'vue';
 | 
			
		||||
import { i18n } from '@/i18n';
 | 
			
		||||
import { instance } from '@/instance';
 | 
			
		||||
import { host } from '@/config';
 | 
			
		||||
import MkButton from '@/components/MkButton.vue';
 | 
			
		||||
import { defaultStore } from '@/store';
 | 
			
		||||
import * as os from '@/os';
 | 
			
		||||
import { $i } from '@/account';
 | 
			
		||||
import { i18n } from '@/i18n';
 | 
			
		||||
 | 
			
		||||
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%;
 | 
			
		||||
		height: 100%;
 | 
			
		||||
		padding: 50%;
 | 
			
		||||
		pointer-events: none;
 | 
			
		||||
 | 
			
		||||
		&.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>
 | 
			
		||||
<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>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { } from 'vue';
 | 
			
		||||
 | 
			
		||||
const props = withDefaults(defineProps<{
 | 
			
		||||
	static?: boolean;
 | 
			
		||||
}>(), {
 | 
			
		||||
	static: false,
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" module>
 | 
			
		||||
@keyframes ellipsis {
 | 
			
		||||
	0%, 80%, 100% {
 | 
			
		||||
| 
						 | 
				
			
			@ -15,7 +25,9 @@
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
.root {
 | 
			
		||||
	
 | 
			
		||||
	&.static > .dot {
 | 
			
		||||
		animation-play-state: paused;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.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;"/>
 | 
			
		||||
			</g>
 | 
			
		||||
		</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)">
 | 
			
		||||
				<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>
 | 
			
		||||
| 
						 | 
				
			
			@ -19,11 +19,13 @@
 | 
			
		|||
import { } from 'vue';
 | 
			
		||||
 | 
			
		||||
const props = withDefaults(defineProps<{
 | 
			
		||||
	static?: boolean;
 | 
			
		||||
	inline?: boolean;
 | 
			
		||||
	colored?: boolean;
 | 
			
		||||
	mini?: boolean;
 | 
			
		||||
	em?: boolean;
 | 
			
		||||
}>(), {
 | 
			
		||||
	static: false,
 | 
			
		||||
	inline: false,
 | 
			
		||||
	colored: true,
 | 
			
		||||
	mini: false,
 | 
			
		||||
| 
						 | 
				
			
			@ -97,5 +99,9 @@ const props = withDefaults(defineProps<{
 | 
			
		|||
 | 
			
		||||
.fg {
 | 
			
		||||
	animation: spinner 0.5s linear infinite;
 | 
			
		||||
 | 
			
		||||
	&.static {
 | 
			
		||||
		animation-play-state: paused;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</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">
 | 
			
		||||
export type Tab = {
 | 
			
		||||
	key: string;
 | 
			
		||||
	onClick?: (ev: MouseEvent) => void;
 | 
			
		||||
} & (
 | 
			
		||||
	| {
 | 
			
		||||
			iconOnly?: false;
 | 
			
		||||
			title: string;
 | 
			
		||||
			icon?: string;
 | 
			
		||||
	iconOnly?: boolean;
 | 
			
		||||
	onClick?: (ev: MouseEvent) => void;
 | 
			
		||||
} & {
 | 
			
		||||
		}
 | 
			
		||||
	| {
 | 
			
		||||
			iconOnly: true;
 | 
			
		||||
	iccn: string;
 | 
			
		||||
};
 | 
			
		||||
			icon: string;
 | 
			
		||||
		}
 | 
			
		||||
);
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<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<{
 | 
			
		||||
	time: Date | string | number | null;
 | 
			
		||||
	origin?: Date | null;
 | 
			
		||||
	mode?: 'relative' | 'absolute' | 'detail';
 | 
			
		||||
}>(), {
 | 
			
		||||
	origin: null,
 | 
			
		||||
	mode: 'relative',
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -25,7 +27,7 @@ const _time = props.time == null ? NaN :
 | 
			
		|||
const invalid = Number.isNaN(_time);
 | 
			
		||||
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>(() => {
 | 
			
		||||
	if (props.mode === 'absolute') return ''; // absoluteではrelativeを使わないので計算しない
 | 
			
		||||
	if (invalid) return i18n.ts._ago.invalid;
 | 
			
		||||
| 
						 | 
				
			
			@ -46,7 +48,7 @@ const relative = $computed<string>(() => {
 | 
			
		|||
let tickId: number;
 | 
			
		||||
 | 
			
		||||
function tick() {
 | 
			
		||||
	now = (new Date()).getTime();
 | 
			
		||||
	now = props.origin ?? (new Date()).getTime();
 | 
			
		||||
	const ago = (now - _time) / 1000/*ms*/;
 | 
			
		||||
	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,
 | 
			
		||||
			categoryPercentage: 0.7,
 | 
			
		||||
			fill: true,
 | 
			
		||||
		} satisfies ChartDataset, extra);
 | 
			
		||||
		/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
 | 
			
		||||
		} satisfies ChartData, extra);
 | 
			
		||||
		 */
 | 
			
		||||
		}, extra);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	chartInstance = new Chart(chartEl, {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -113,6 +113,9 @@ async function renderChart() {
 | 
			
		|||
					const a = c.chart.chartArea ?? {};
 | 
			
		||||
					return (a.bottom - a.top) / 7 - marginEachCell;
 | 
			
		||||
				},
 | 
			
		||||
			/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
 | 
			
		||||
			}] satisfies ChartData[],
 | 
			
		||||
			 */
 | 
			
		||||
			}],
 | 
			
		||||
		},
 | 
			
		||||
		options: {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -76,7 +76,10 @@ async function renderChart() {
 | 
			
		|||
			borderRadius: 4,
 | 
			
		||||
			barPercentage: 0.9,
 | 
			
		||||
			fill: true,
 | 
			
		||||
		} satisfies ChartDataset, extra);
 | 
			
		||||
		/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
 | 
			
		||||
		} satisfies ChartData, extra);
 | 
			
		||||
		 */
 | 
			
		||||
		}, extra);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	chartInstance = new Chart(chartEl, {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -77,7 +77,10 @@ async function renderChart() {
 | 
			
		|||
			barPercentage: 0.7,
 | 
			
		||||
			categoryPercentage: 0.7,
 | 
			
		||||
			fill: true,
 | 
			
		||||
		} satisfies ChartDataset, extra);
 | 
			
		||||
		/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
 | 
			
		||||
		} satisfies ChartData, extra);
 | 
			
		||||
		 */
 | 
			
		||||
		}, extra);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	chartInstance = new Chart(chartEl, {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -443,11 +443,14 @@ export const ACHIEVEMENT_BADGES = {
 | 
			
		|||
		bg: 'linear-gradient(0deg, rgb(144, 224, 255), rgb(255, 168, 252))',
 | 
			
		||||
		frame: 'bronze',
 | 
			
		||||
	},
 | 
			
		||||
/* @see <https://github.com/misskey-dev/misskey/pull/10365#discussion_r1155511107>
 | 
			
		||||
} as const satisfies Record<typeof ACHIEVEMENT_TYPES[number], {
 | 
			
		||||
	img: string;
 | 
			
		||||
	bg: string | null;
 | 
			
		||||
	frame: 'bronze' | 'silver' | 'gold' | 'platinum';
 | 
			
		||||
}>;
 | 
			
		||||
 */
 | 
			
		||||
} as const;
 | 
			
		||||
 | 
			
		||||
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",
 | 
			
		||||
		"./**/*.ts",
 | 
			
		||||
		"./**/*.vue"
 | 
			
		||||
	],
 | 
			
		||||
	"exclude": [
 | 
			
		||||
		".storybook/**/*",
 | 
			
		||||
	]
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,6 @@
 | 
			
		|||
import path from 'path';
 | 
			
		||||
import pluginVue from '@vitejs/plugin-vue';
 | 
			
		||||
import { defineConfig } from 'vite';
 | 
			
		||||
import { configDefaults as vitestConfigDefaults } from 'vitest/config';
 | 
			
		||||
import { type UserConfig, defineConfig } from 'vite';
 | 
			
		||||
 | 
			
		||||
import locales from '../../locales';
 | 
			
		||||
import meta from '../../package.json';
 | 
			
		||||
| 
						 | 
				
			
			@ -38,7 +37,7 @@ function toBase62(n: number): string {
 | 
			
		|||
	return result;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default defineConfig(({ command, mode }) => {
 | 
			
		||||
export function getConfig(): UserConfig {
 | 
			
		||||
	return {
 | 
			
		||||
		base: '/vite/',
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -62,7 +61,7 @@ export default defineConfig(({ command, mode }) => {
 | 
			
		|||
 | 
			
		||||
		css: {
 | 
			
		||||
			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, '');
 | 
			
		||||
					if (process.env.NODE_ENV === 'production') {
 | 
			
		||||
						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": {
 | 
			
		||||
		"@microsoft/api-extractor": "7.34.4",
 | 
			
		||||
		"@swc/jest": "0.2.24",
 | 
			
		||||
		"@types/jest": "29.5.0",
 | 
			
		||||
		"@types/node": "18.15.11",
 | 
			
		||||
		"@typescript-eslint/eslint-plugin": "5.57.0",
 | 
			
		||||
		"@typescript-eslint/parser": "5.57.0",
 | 
			
		||||
		"@swc/jest": "0.2.24",
 | 
			
		||||
		"eslint": "8.37.0",
 | 
			
		||||
		"jest": "^29.5.0",
 | 
			
		||||
		"jest-fetch-mock": "^3.0.3",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										5580
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										5580
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue