feat: migrate to turbo (#22)

* feat: migrate top turbo

* ci: ci test

* fix: fix codeql issues

* feat: ci test

* chore: lint

* chore: misc changes

* feat: rename vite helpers

* feat: use fetch to handle assets

* feat: update directory

* feat: fetch charword table

* feat: migrate download game data and detect missing voice files

* feat: symlink relative path

* feat: finish wrangler upload

* feat: migrate wrangler download

* feat: finish

* chore: auto update

* ci: update ci

* ci: update ci

---------

Co-authored-by: Halyul <Halyul@users.noreply.github.com>
This commit is contained in:
Haoyu Xu
2025-02-22 15:11:30 +08:00
committed by GitHub
parent 17c61ce5d4
commit d6e7bc20d3
352 changed files with 12911 additions and 9411 deletions

26
apps/directory/.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
data

View File

@@ -0,0 +1,3 @@
dist
data
auto_update

View File

@@ -0,0 +1,30 @@
import js from '@eslint/js'
import react from 'eslint-plugin-react'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import baseConfig from '@aklive2d/eslint-config'
/** @type {import('eslint').Config} */
export default [
...baseConfig,
{ ignores: ['dist'] },
{
settings: { react: { version: '18.3' } },
plugins: {
react,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
...reactHooks.configs.recommended.rules,
'react/jsx-no-target-blank': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

86
apps/directory/index.html Normal file
View File

@@ -0,0 +1,86 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%VITE_APP_TITLE%</title>
<script
id="counterscale-script"
src="%VITE_INSIGHT_URL%"
defer
></script>
<style>
.loader {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
opacity: 1;
}
.loader.loaded {
opacity: 0;
transition: opacity cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
}
.loader .icon,
.loader .flasher {
display: block;
margin: 0 auto;
width: 2rem;
height: 2rem;
border-left: 0.3rem solid;
border-bottom: 0.3rem solid;
border-right: 0.3rem solid;
border-top: 0.3rem solid;
transform: rotate(-45deg);
box-shadow:
inset 0 0 16px 2px rgb(200 14 0),
0 0 18px 5px rgb(200 14 0);
border-color: rgba(54, 0, 0, 87%);
}
.loader .flasher {
position: absolute;
box-shadow: unset;
animation: icon-flash 1.2s cubic-bezier(0.2, 0.6, 0.2, 1)
infinite;
}
@media (prefers-color-scheme: dark) {
.loader {
background-color: #131313;
}
}
@media (prefers-color-scheme: light) {
.loader {
background-color: #ececec;
}
}
@keyframes icon-flash {
100% {
opacity: 0;
width: 4rem;
height: 4rem;
border-color: rgb(0, 0, 0);
}
}
</style>
</head>
<body>
<div id="root"></div>
<div class="loader">
<span class="icon"></span>
<span class="flasher"></span>
</div>
<script type="module" src="/src/App.jsx"></script>
</body>
</html>

View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"!/*": ["src/*"]
}
}
}

View File

@@ -0,0 +1,43 @@
{
"name": "@aklive2d/directory",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev:directory": "vite --clearScreen false",
"build": "mode=build node runner.js",
"preview:directory": "vite preview",
"lint": "eslint \"src/**/*.js\" \"src/**/*.jsx\" && stylelint \"src/**/*.css\" \"src/**/*.scss\" && prettier --check ."
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"jotai": "^2.11.3",
"react-router-dom": "^7.1.5",
"react-simple-typewriter": "^5.0.1",
"reset-css": "^5.0.2",
"@aklive2d/eslint-config": "workspace:*",
"@aklive2d/stylelint-config": "workspace:*",
"@aklive2d/postcss-config": "workspace:*",
"@aklive2d/config": "workspace:*",
"@aklive2d/libs": "workspace:*",
"@aklive2d/assets": "workspace:*",
"@aklive2d/operator": "workspace:*",
"@aklive2d/vite-helpers": "workspace:*",
"@aklive2d/showcase": "workspace:*",
"@aklive2d/module": "workspace:*",
"@aklive2d/prettier-config": "workspace:*"
},
"devDependencies": {
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react-swc": "^3.5.0",
"vite": "^6.1.0",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.18",
"prop-types": "^15.8.1",
"sass": "^1.84.0",
"autoprefixer": "^10.4.20"
}
}

View File

@@ -0,0 +1,5 @@
import baseConfig from '@aklive2d/postcss-config'
/** @type {import('postcss').Config} */
export default {
...baseConfig,
}

View File

@@ -0,0 +1,11 @@
import baseConfig from '@aklive2d/prettier-config'
/**
* @type {import("prettier").Config}
*/
const config = {
...baseConfig,
semi: false,
}
export default config

24
apps/directory/runner.js Normal file
View File

@@ -0,0 +1,24 @@
import { build as viteBuild } from 'vite'
import { envParser } from '@aklive2d/libs'
const build = async (namesToBuild) => {
if (!namesToBuild.length) {
// skip as directory can only build
// when all operators are built
await viteBuild()
}
}
async function main() {
const { name } = envParser.parse({
name: {
type: 'string',
short: 'n',
multiple: true,
default: [],
},
})
await build(name)
}
main()

View File

@@ -0,0 +1,23 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import Root from '@/routes/Root'
import Error from '@/routes/Error'
import routes from '@/routes'
import '@/App.scss'
import 'reset-css'
const router = createBrowserRouter([
{
path: '/',
element: <Root />,
errorElement: <Error />,
children: routes.filter((item) => item.routeable),
},
])
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
)

103
apps/directory/src/App.scss Normal file
View File

@@ -0,0 +1,103 @@
@import 'https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;500;700&family=Noto+Sans+KR:wght@400;500;700&family=Noto+Sans+SC:wght@400;500;700&family=Noto+Sans:wght@400;500;700&display=swap';
@import 'https://fonts.cdnfonts.com/css/bender';
@import 'https://fonts.cdnfonts.com/css/geometos';
@mixin light-theme() {
--text-color: rgb(0 0 0 / 87%);
--text-color-full: #000;
--secondary-text-color: #97958d;
--date-color: rgb(0 0 0 / 20%);
--border-color: #8f8f8f;
--link-highlight-color: #33b5e5;
--drawer-background-color: rgb(255 255 255 / 88%);
--root-background-color: #ececec;
--home-item-hover-background-color: rgb(188 188 188 / 30%);
--home-item-background-linear-gradient-color: rgb(0 0 0 / 10%);
--home-item-outline-color: rgb(41 41 41 / 30%);
--button-color: #999;
}
@mixin dark-theme() {
--text-color: rgb(255 255 255 / 87%);
--text-color-full: #fff;
--secondary-text-color: #686a72;
--date-color: rgb(255 255 255 / 20%);
--border-color: #707070;
--link-highlight-color: #33b5e5;
--drawer-background-color: rgb(0 0 0 / 88%);
--root-background-color: #131313;
--home-item-hover-background-color: rgb(67 67 67 / 30%);
--home-item-background-linear-gradient-color: rgb(255 255 255 / 10%);
--home-item-outline-color: rgb(214 214 214 / 30%);
--button-color: #666;
}
@media (prefers-color-scheme: dark) {
:root {
@include dark-theme;
}
}
@media (prefers-color-scheme: light) {
:root {
@include light-theme;
}
}
:root {
font-family:
Geometos, 'Noto Sans SC', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans',
sans-serif;
font-size: 16px;
line-height: 1.2em;
font-weight: 400;
color: var(--text-color);
background-color: var(--root-background-color);
min-height: 100vh;
@media only screen and (width <= 430px) {
& {
font-size: 12px;
}
}
@media only screen and (width <= 1600px) {
& {
font-size: 14px;
}
}
}
#root {
display: flex;
flex-flow: column nowrap;
justify-content: flex-start;
align-items: stretch;
min-height: 100vh;
}
:root::-webkit-scrollbar {
width: 12px;
height: 12px;
}
#root::-webkit-scrollbar-track {
background-color: transparent;
}
:root::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border: 5px solid transparent;
background-clip: padding-box;
transition: all 0.3s ease-in-out;
}
:root::-webkit-scrollbar-thumb:hover {
border: 4px solid transparent;
background-color: var(--text-color);
}
a {
color: var(--text-color);
text-decoration: none;
}

View File

@@ -0,0 +1,9 @@
import PropTypes from 'prop-types'
import classes from './scss/border.module.scss'
export default function Border(props) {
return <section className={classes.border}>{props.children}</section>
}
Border.propTypes = {
children: PropTypes.node,
}

View File

@@ -0,0 +1,28 @@
import PropTypes from 'prop-types'
export default function CharIcon(props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={props.viewBox}
style={props.style}
>
{props.type === 'operator' ? (
<g>
<path d="M89 17.5 30.4 57 24.3 71.4 82.9 32.6Z"></path>
<path d="M0 17.5 58.6 57 64.7 71.4 6.1 32.7Z"> </path>
<path d="M89 0 30.4 39.5 24.3 53.9 82.9 15.1Z"> </path>
<path d="M0 0 58.6 39.5 64.7 53.9 6.1 15.2Z"> </path>
</g>
) : (
<path d="M90.4 50.6l-39.8-23.5v-4c0-4.5-5-6.5-5-6.5a5.4 5.4 0 012.2-10.1c2.7 0 5.3 1.5 5.5 4.8.4 5.3 6.4 3.9 6.4-.3a11.7 11.7 0 00-12-11c-9 0-11.6 8.8-11.6 11.6a11.5 11.5 0 001.6 6.2c2.2 3.8 6.6 4.3 6.6 6.8v2.5L4.2 50.7c-4 2.3-4.7 7.3-3.8 10.3a9.1 9.1 0 009.1 6.4h75.2c5.9 0 8.6-3.4 9.5-6.3C95 58.1 95 53.4 90.4 50.6Zm-5.6 10.3h-75.2c-2.4.1-4-3.3-1.5-4.8l39.2-22.9 39 22.8A2.7 2.7 0 0184.7 60.8Z" />
)}
</svg>
)
}
CharIcon.propTypes = {
viewBox: PropTypes.string,
type: PropTypes.string,
style: PropTypes.object,
}

View File

@@ -0,0 +1,99 @@
import { useState } from 'react'
import PropTypes from 'prop-types'
import classes from './scss/dropdown.module.scss'
export default function Dropdown(props) {
const [hidden, setHidden] = useState(true)
const toggleDropdown = () => {
setHidden(!hidden)
}
return (
<>
<section
className={`${classes.dropdown} ${hidden ? '' : classes.active} ${props.className ? props.className : ''} ${props.left ? classes.left : ''}`}
>
<section
className={classes.text}
onClick={() => toggleDropdown()}
>
<span className={classes.content}>{props.text}</span>
<span
className={classes.icon}
style={props.iconStyle}
></span>
<section className={classes.popup}>
<span className={classes.text}>{props.altText}</span>
</section>
</section>
<ul className={classes.menu} style={props.activeColor}>
{props.menu.map((item) => {
switch (item.type) {
case 'date': {
return (
<section
key={item.name}
className={classes.date}
>
<section className={classes.line} />
<section className={classes.text}>
{item.name}
</section>
</section>
)
}
case 'custom': {
return item.component
}
default: {
return (
<li
key={item.name}
className={`${classes.item} ${item.name === props.text || (props.activeRule && props.activeRule(item)) ? classes.active : ''}`}
onClick={() => {
props.onClick(item)
toggleDropdown()
}}
style={
item.color
? { color: item.color }
: {}
}
>
{item.icon ? (
<section
className={classes['item-icon']}
>
{item.icon}
</section>
) : null}
<section className={classes.text}>
{item.name}
</section>
</li>
)
}
}
})}
</ul>
<section
className={classes.overlay}
hidden={hidden}
onClick={() => toggleDropdown()}
/>
</section>
</>
)
}
Dropdown.propTypes = {
className: PropTypes.string,
text: PropTypes.string,
menu: PropTypes.array,
onClick: PropTypes.func,
activeColor: PropTypes.object,
activeRule: PropTypes.func,
altText: PropTypes.string,
iconStyle: PropTypes.object,
left: PropTypes.bool,
}

View File

@@ -0,0 +1,48 @@
import { useState } from 'react'
import classes from './scss/popup.module.scss'
import ReturnButton from '@/component/return_button'
import Border from '@/component/border'
import PropTypes from 'prop-types'
export default function Popup(props) {
const [hidden, setHidden] = useState(true)
const toggle = () => {
setHidden(!hidden)
}
return (
<>
<section
className={`${classes.popup} ${hidden ? '' : classes.active}`}
>
<section className={classes.wrapper}>
<section className={classes.title}>
<section className={classes.text}>
{props.title}
</section>
<ReturnButton
onClick={toggle}
className={classes['return-button']}
/>
</section>
<Border />
<section className={classes.content}>
{props.children}
</section>
</section>
<section
className={`${classes.overlay} ${hidden ? '' : classes.active}`}
onClick={() => toggle()}
/>
</section>
<span className={classes['entry-text']} onClick={toggle}>
{props.title}
</span>
</>
)
}
Popup.propTypes = {
title: PropTypes.string,
children: PropTypes.node,
}

View File

@@ -0,0 +1,38 @@
import PropTypes from 'prop-types'
import classes from './scss/return_button.module.scss'
export default function ReturnButton(props) {
return (
<>
<section
className={`${classes['return-button']} ${props.className ? props.className : ''}`}
onClick={() => props.onClick()}
>
<section className={classes.wrapper}>
<section className={classes['arrow-left']}></section>
<section className={classes.bar}></section>
<section className={classes['arrow-right']}></section>
</section>
<section className={classes.wrapper}>
<section className={classes['arrow-left']}></section>
<section className={classes.bar}></section>
<section className={classes['arrow-right']}></section>
</section>
<section className={classes.wrapper}>
<section className={classes['arrow-left']}></section>
<section className={classes.bar}></section>
<section className={classes['arrow-right']}></section>
</section>
<section className={classes.wrapper}>
<section className={classes['arrow-left']}></section>
<section className={classes.bar}></section>
<section className={classes['arrow-right']}></section>
</section>
</section>
</>
)
}
ReturnButton.propTypes = {
onClick: PropTypes.func,
className: PropTypes.string,
}

View File

@@ -0,0 +1,30 @@
.border {
position: relative;
bottom: 1px;
border-bottom: 1px solid var(--text-color);
@media only screen and (width <= 430px) {
& {
margin: 0 1rem;
}
}
&::before,
&::after {
content: '';
display: block;
position: absolute;
width: 5px;
height: 5px;
top: -2px;
background-color: var(--text-color);
}
&::before {
right: 100%;
}
&::after {
left: 100%;
}
}

View File

@@ -0,0 +1,187 @@
.dropdown {
position: relative;
display: inline-block;
user-select: none;
z-index: 2;
padding: 0.5em;
cursor: pointer;
.text {
display: flex;
flex-direction: row;
align-items: center;
color: var(--text-color);
height: 2em;
min-width: 2em;
.popup {
opacity: 0;
position: absolute;
background-color: var(--root-background-color);
width: max-content;
height: max-content;
max-height: 61.8vh;
max-width: 61.8vw;
z-index: -1;
top: 2.5em;
right: 0;
cursor: auto;
transition: all cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
overflow: auto;
padding: 0.5rem;
border: 1px solid var(--border-color);
visibility: hidden;
.text {
font-size: 1rem;
}
}
&:hover {
.popup {
visibility: unset;
opacity: 1;
}
}
}
.content {
padding-right: 1.2em;
height: 1em;
}
.icon {
position: absolute;
bottom: 0.5em;
right: 0.6em;
width: 0.5em;
height: 0.5em;
display: inline-block;
vertical-align: middle;
border-left: 0.15em solid var(--text-color);
border-bottom: 0.15em solid var(--text-color);
border-right: 0.15em solid var(--text-color);
border-top: 0.15em solid var(--text-color);
transform: translateY(-0.7em) rotate(-45deg);
}
.menu {
scrollbar-gutter: stable;
opacity: 0;
position: absolute;
background-color: var(--root-background-color);
width: max-content;
max-height: 61.8vh;
max-width: 61.8vw;
z-index: -1;
top: 2.5em;
right: 0;
display: flex;
align-items: stretch;
flex-flow: column nowrap;
transition: all cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
overflow: auto;
padding: 0.5rem;
border: 1px solid var(--border-color);
visibility: hidden;
color: var(--link-highlight-color);
cursor: auto;
&.left {
left: 0;
right: unset;
}
.date {
font-family:
Bender, 'Noto Sans SC', 'Noto Sans JP', 'Noto Sans KR',
'Noto Sans', sans-serif;
font-weight: bold;
font-size: 1.5rem;
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
width: 100%;
.line {
height: 1px;
flex-grow: 1;
background-color: var(--text-color);
margin: 0.5rem;
}
}
.item {
cursor: pointer;
padding: 0.5rem;
font-size: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
.text {
flex: 1;
transition: color cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
margin-left: 1rem;
}
.item-icon svg {
transition: fill cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
width: 1rem;
fill: var(--text-color);
}
&:hover,
&:focus,
&.active {
.text {
color: currentcolor;
}
.item-icon svg {
fill: currentcolor;
}
}
}
}
&.left {
/* stylelint-disable no-descending-specificity */
.popup,
.menu {
left: 0;
right: unset;
}
}
.overlay {
z-index: -1;
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
cursor: auto;
}
&.active,
&:hover {
.icon {
animation: icon-flash 2s cubic-bezier(0.65, 0.05, 0.36, 1) infinite;
}
}
&.active {
.menu {
visibility: visible;
opacity: 1;
z-index: 2;
}
}
}
@keyframes icon-flash {
50% {
opacity: 0.2;
}
}

View File

@@ -0,0 +1,96 @@
.entry-text {
cursor: pointer;
}
.popup {
position: fixed;
inset: 0;
overflow: hidden;
opacity: 0;
z-index: -1;
border: unset;
transition: all cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 1.2rem;
.wrapper {
display: flex;
flex-flow: column nowrap;
align-items: stretch;
max-width: 480px;
height: fit-content;
margin: 0 auto;
background-color: var(--root-background-color);
border: 1px solid var(--border-color);
padding: 2rem;
}
.title {
font-size: 3rem;
font-weight: 700;
display: flex;
flex-direction: row;
place-content: center space-between;
align-items: center;
text-transform: uppercase;
font-family:
Geometos, 'Noto Sans SC', 'Noto Sans JP', 'Noto Sans KR',
'Noto Sans', sans-serif;
.return-button {
color: var(--button-color);
transition: color cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
&:hover {
color: var(--text-color);
}
}
}
.text {
flex-grow: 1;
margin-right: 3rem;
}
.content {
line-height: 1.3em;
padding: 1rem 1rem 0;
user-select: text;
}
.overlay {
position: absolute;
inset: 0;
z-index: -1;
opacity: 0;
background-color: var(--root-background-color);
transition: all cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
&.active {
opacity: 0.5;
visibility: visible;
}
}
&.active {
opacity: 1;
z-index: 10;
}
@media (width <= 768px) {
.title {
font-size: 2rem;
}
.content {
font-size: 1rem;
}
.return-button {
transform: scale(0.8);
}
}
}

View File

@@ -0,0 +1,55 @@
.return-button {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 0.6rem 0;
width: 3rem;
cursor: pointer;
%arrow-shared {
border-top: 0.24rem solid transparent;
border-bottom: 0.24rem solid transparent;
}
.wrapper {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
&:nth-child(1) {
transform: translateY(-0.1rem) rotate(45deg);
}
&:nth-child(2) {
transform: translateY(-0.1rem) translate(90%, -100%) rotate(-45deg);
}
&:nth-child(3) {
transform: translateY(-0.1rem) translateY(150%) rotate(315deg);
}
&:nth-child(4) {
transform: translateY(-0.1rem) translate(90%, 50%) rotate(225deg);
}
}
.bar {
width: 1rem;
height: 0.4rem;
background-color: currentcolor;
transition: transform cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
}
.arrow-left {
@extend %arrow-shared;
border-right: 0.3rem solid currentcolor;
}
.arrow-right {
@extend %arrow-shared;
border-left: 0.3rem solid currentcolor;
}
}

View File

@@ -0,0 +1,78 @@
.search-box {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: stretch;
margin: 0.5rem;
.icon {
position: absolute;
width: 0.8rem;
height: 0.8rem;
display: inline-block;
vertical-align: middle;
border-left: 0.15rem solid var(--text-color-full);
border-bottom: 0.15rem solid var(--text-color-full);
border-right: 0.15rem solid var(--text-color-full);
border-top: 0.15rem solid var(--text-color-full);
transform: translate(0.2rem, 0.3rem) rotate(-45deg);
}
.icon-dot {
position: absolute;
background-color: var(--text-color-full);
width: 0.15rem;
height: 0.6rem;
transform: translate(1.2rem, 1.1rem) rotate(-45deg);
}
.input {
flex-grow: 1;
font-size: 1.5rem;
width: 100%;
margin-left: 2rem;
padding-right: 2rem;
background-color: transparent;
border: unset;
border-bottom: 0.15em solid var(--home-item-outline-color);
color: var(--text-color);
transition: all cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
}
.input:focus,
.input:hover {
outline: none;
border-bottom: 0.15em solid var(--text-color);
}
.icon-clear {
position: absolute;
right: 1rem;
width: 2rem;
height: 2rem;
cursor: pointer;
opacity: 0;
transition: all cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
visibility: hidden;
&.active {
opacity: 1;
visibility: unset;
}
.line {
position: absolute;
width: 2rem;
height: 0.2rem;
background-color: var(--text-color);
&:nth-child(1) {
transform: translate(0, 0.8rem) rotate(45deg);
}
&:nth-child(2) {
transform: translate(0, 0.8rem) rotate(-45deg);
}
}
}
}

View File

@@ -0,0 +1,64 @@
.switch {
position: relative;
user-select: none;
z-index: 2;
padding: 8px 36px 8px 8px;
cursor: pointer;
display: flex;
flex-direction: row;
align-items: center;
color: var(--secondary-text-color);
transition: color cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
.content {
padding-right: 8px;
}
.wrapper {
color: var(--secondary-text-color);
transition: all cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
.icon {
position: absolute;
bottom: 0.65em;
right: 18px;
width: 0.5em;
height: 0.5em;
display: inline-block;
vertical-align: middle;
border-left: 0.15em solid currentcolor;
border-bottom: 0.15em solid currentcolor;
border-right: 0.15em solid currentcolor;
border-top: 0.15em solid currentcolor;
transform: translate(0, -0.15em) rotate(-45deg);
transition:
right cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s,
background-color cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
}
.line {
position: absolute;
bottom: 1.1em;
right: 6px;
width: 18px;
height: 0.15em;
display: inline-block;
vertical-align: middle;
background-color: currentcolor;
z-index: -1;
}
}
&.active {
color: var(--text-color);
.wrapper {
color: var(--text-color-full);
.icon {
background-color: currentcolor;
right: 0;
}
}
}
}

View File

@@ -0,0 +1,43 @@
.totop-button {
position: fixed;
user-select: none;
z-index: 2;
cursor: pointer;
right: 2rem;
bottom: 1rem;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
width: 3rem;
height: 3rem;
opacity: 0;
visibility: hidden;
transition: opacity cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
&.show {
opacity: 1;
visibility: unset;
}
.bar {
position: absolute;
width: 100%;
height: 0.3rem;
background-color: var(--text-color-full);
will-change: auto;
}
.bar:nth-child(1) {
transform: rotateZ(45deg) scaleX(0.5) translateX(45%);
}
.bar:nth-child(2) {
transform: rotateZ(-45deg) scaleX(0.5) translateX(-45%);
}
.bar:nth-child(3),
.bar:nth-child(4) {
transform: translateY(450%) rotateZ(90deg) scaleX(0.5);
}
}

View File

@@ -0,0 +1,50 @@
import { useState } from 'react'
import PropTypes from 'prop-types'
import classes from './scss/search_box.module.scss'
import { useI18n } from '@/state/language'
export default function SearchBox(props) {
const { i18n } = useI18n()
const [searchField, setSearchField] = useState('')
const filterBySearch = (event) => {
const query = event.target.value
props.handleOnChange(query)
setSearchField(query)
}
return (
<>
<section
className={`${classes['search-box']} ${props.className ? props.className : ''}`}
>
<section className={classes.icon} />
<section className={classes['icon-dot']} />
<input
type="text"
className={classes.input}
placeholder={i18n(props.altText)}
onChange={filterBySearch}
value={searchField}
/>
<section
className={`${classes['icon-clear']} ${searchField === '' ? '' : classes.active}`}
onClick={() => {
setSearchField('')
props.handleOnChange('')
}}
>
<section className={classes.line} />
<section className={classes.line} />
</section>
</section>
</>
)
}
SearchBox.propTypes = {
className: PropTypes.string,
text: PropTypes.string,
altText: PropTypes.string,
handleOnChange: PropTypes.func,
searchField: PropTypes.string,
}

View File

@@ -0,0 +1,35 @@
import { useState, useEffect } from 'react'
import PropTypes from 'prop-types'
import classes from './scss/switch.module.scss'
import { useI18n } from '@/state/language'
export default function Switch(props) {
const [on, setOn] = useState(props.on)
const { i18n } = useI18n()
useEffect(() => {
setOn(props.on)
}, [props.on])
return (
<section
className={`${classes.switch} ${on ? classes.active : ''}`}
onClick={() => {
if (props.handleOnClick) {
props.handleOnClick(!on)
}
}}
>
<span className={classes.text}>{i18n(props.text)}</span>
<section className={classes.wrapper}>
<span className={classes.line}></span>
<span className={classes.icon}></span>
</section>
</section>
)
}
Switch.propTypes = {
on: PropTypes.bool,
text: PropTypes.string,
handleOnClick: PropTypes.func,
}

View File

@@ -0,0 +1,64 @@
import { useEffect, useState, useCallback } from 'react'
import PropTypes from 'prop-types'
import classes from './scss/totop_button.module.scss'
export default function ToTopButton(props) {
const [hidden, setHidden] = useState(true)
useEffect(() => {
const handleButton = () => {
const scrollBarPos = window.scrollY || 0
setHidden(!(scrollBarPos > 100))
}
window.addEventListener('scroll', handleButton)
return () => {
window.removeEventListener('scroll', handleButton)
}
}, [])
const smoothScroll = useCallback((target) => {
const targetElement = document.querySelector(target)
const targetPosition =
targetElement.getBoundingClientRect().top + window.scrollY
const startPosition = window.scrollY
const distance = targetPosition - startPosition
const duration = 1000
let start = null
window.requestAnimationFrame(step)
function step(timestamp) {
if (!start) start = timestamp
const progress = timestamp - start
window.scrollTo(
0,
easeInOutCubic(progress, startPosition, distance, duration)
)
if (progress < duration) window.requestAnimationFrame(step)
}
function easeInOutCubic(t, b, c, d) {
t /= d / 2
if (t < 1) return (c / 2) * t * t * t + b
t -= 2
return (c / 2) * (t * t * t + 2) + b
}
}, [])
return (
<>
<section
className={`${classes['totop-button']} ${hidden ? '' : classes.show} ${props.className ? props.className : ''}`}
onClick={() => {
smoothScroll('#root')
}}
>
<section className={classes.bar}></section>
<section className={classes.bar}></section>
<section className={classes.bar}></section>
<section className={classes.bar}></section>
</section>
</>
)
}
ToTopButton.propTypes = {
onClick: PropTypes.func,
className: PropTypes.string,
}

View File

@@ -0,0 +1,46 @@
import { useEffect, useRef } from 'react'
import PropTypes from 'prop-types'
export default function VoiceElement({ src, replay, handleAduioStateChange }) {
const audioRef = useRef(null)
useEffect(() => {
if (src) {
audioRef.current.src = src
audioRef.current.play()
} else {
audioRef.current.pause()
}
}, [src])
useEffect(() => {
if (replay) {
audioRef.current.currentTime = 0
audioRef.current.play()
}
}, [replay])
return (
<audio
ref={audioRef}
preload="auto"
autoPlay
onEnded={(e) => {
if (handleAduioStateChange) handleAduioStateChange(e, 'ended')
}}
onPlay={(e) => {
if (handleAduioStateChange) handleAduioStateChange(e, 'play')
}}
onPause={(e) => {
if (handleAduioStateChange) handleAduioStateChange(e, 'pause')
}}
>
<source type="audio/ogg" />
</audio>
)
}
VoiceElement.propTypes = {
src: PropTypes.string,
handleAduioStateChange: PropTypes.func,
replay: PropTypes.bool,
}

View File

@@ -0,0 +1,161 @@
{
"available": ["zh-CN", "en-US"],
"key": {
"dynamic_compile": {
"zh-CN": "动态集录",
"en-US": "Dynamic Compile"
},
"home": {
"zh-CN": "首页",
"en-US": "Home"
},
"official_page": {
"zh-CN": "官方页面",
"en-US": "Official Page"
},
"disclaimer": {
"zh-CN": "免责声明",
"en-US": "Disclaimer"
},
"disclaimer_content": {
"zh-CN": "本网站由 Halyul 设立并为明日方舟社区服务Halyul 声明本网站完全独立运营,与 上海鹰角网络科技有限公司, Esoteric Software LLC 或其任何关联实体并无任何联系。",
"en-US": "This website is set up and operated by Halyul for the benefit of the Arknights Community. Halyul hereby states that this website is dedicated, but not related to Hypergryph Co., Ltd, Esoteric Software LLC or any of its affiliated entity."
},
"privacy_policy": {
"zh-CN": "隐私政策",
"en-US": "Privacy Policy"
},
"contact_us": {
"zh-CN": "联系我们",
"en-US": "Contact Us"
},
"all": {
"zh-CN": "综合",
"en-US": "All"
},
"operator": {
"zh-CN": "精英2",
"en-US": "Elite 2"
},
"skin": {
"zh-CN": "时装",
"en-US": "Skin"
},
"voice": {
"zh-CN": "语音",
"en-US": "Voice"
},
"music": {
"zh-CN": "音乐",
"en-US": "Music"
},
"showcase": {
"zh-CN": "壁纸",
"en-US": "Wallpaper"
},
"directory": {
"zh-CN": "目录页",
"en-US": "Directory Page"
},
"animation": {
"zh-CN": "动画",
"en-US": "Animation"
},
"backgrounds": {
"zh-CN": "背景",
"en-US": "Backgrounds"
},
"CN_MANDARIN": {
"zh-CN": "普通话",
"en-US": "Mandarin"
},
"JP": {
"zh-CN": "日语",
"en-US": "Japanese"
},
"KR": {
"zh-CN": "韩语",
"en-US": "Korean"
},
"EN": {
"zh-CN": "英语",
"en-US": "English"
},
"ITA": {
"zh-CN": "意大利语",
"en-US": "Italian"
},
"CN_TOPOLECT": {
"zh-CN": "中文方言",
"en-US": "Chinese Topolect"
},
"steam_workshop": {
"zh-CN": "壁纸引擎版",
"en-US": "Wallpaper Engine Version"
},
"external_links": {
"zh-CN": "外部链接",
"en-US": "External Links"
},
"web_version": {
"zh-CN": "全功能网页版",
"en-US": "Full Feature Web Version"
},
"idle": {
"zh-CN": "待机",
"en-US": "Idle"
},
"interact": {
"zh-CN": "交互",
"en-US": "Interact"
},
"special": {
"zh-CN": "特殊",
"en-US": "Special"
},
"subtitle": {
"zh-CN": "字幕",
"en-US": "Subtitle"
},
"zh-CN": {
"zh-CN": "简体中文",
"en-US": "Chinese (Simplified)"
},
"en-US": {
"zh-CN": "英语",
"en-US": "English"
},
"zh-TW": {
"zh-CN": "繁体中文",
"en-US": "Chinese (Traditional)"
},
"ja-JP": {
"zh-CN": "日语",
"en-US": "Japanese"
},
"ko-KR": {
"zh-CN": "韩语",
"en-US": "Korean"
},
"switch_language": {
"zh-CN": "🌐 切换语言",
"en-US": "🌐 Switch Language"
},
"fast_navigation": {
"zh-CN": "🧭 快速导航",
"en-US": "🧭 Fast Navigation"
},
"return": {
"zh-CN": "↩️ 返回",
"en-US": "↩️ Return"
},
"search_by_name": {
"zh-CN": "名字搜索",
"en-US": "Search by Name"
},
"new_op_wait_to_update": {
"zh-CN": "个新干员等待更新",
"en-US": "New Operator(s) Waiting to Update"
}
}
}

View File

@@ -0,0 +1,197 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { useNavigate, useRouteError } from 'react-router-dom'
import header from '@/scss/root/header.module.scss'
import classes from '@/scss/error/Error.module.scss'
import { useAtom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
import Switch from '@/component/switch'
import ReturnButton from '@/component/return_button'
import { Typewriter } from 'react-simple-typewriter'
import { useHeader } from '@/state/header'
import VoiceElement from '@/component/voice'
import { spine } from '@aklive2d/module'
import useInsight from '@/state/insight'
import buildConfig from '!/config.json'
const voiceOnAtom = atomWithStorage('voiceOn', false)
const config = buildConfig.error_files
const obj = config.files[Math.floor(Math.random() * config.files.length)]
const filename = obj.key.replace(/#/g, '%23')
const padding = obj.paddings
let lastVoiceState = 'ended'
export default function Error() {
// eslint-disable-next-line no-unused-vars
const _trackEvt = useInsight()
const error = useRouteError()
const navigate = useNavigate()
const { setTitle } = useHeader()
const [voiceOn, setVoiceOn] = useAtom(voiceOnAtom)
const [spineDone, _setSpineDone] = useState(false)
const spineRef = useRef(null)
const spineDoneRef = useRef(spineDone)
const voiceOnRef = useRef(voiceOn)
const [voiceSrc, setVoiceSrc] = useState(null)
const [voiceReplay, setVoiceReplay] = useState(false)
const [spinePlayer, setSpinePlayer] = useState(null)
const setSpineDone = (data) => {
spineDoneRef.current = data
_setSpineDone(data)
}
const content = useMemo(
() => [
'エラー発生。',
'发生错误。',
'Error occured.',
'에러 발생.',
'發生錯誤。',
],
[]
)
useEffect(() => {
console.log(error)
}, [error])
useEffect(() => {
setTitle(content[0])
}, [content, setTitle])
useEffect(() => {
if (!voiceOn) {
setVoiceSrc(null)
} else {
setVoiceSrc(`/${buildConfig.directory_folder}/error.ogg`)
if (spinePlayer) {
spinePlayer.animationState.setAnimation(0, 'Interact', false, 0)
spinePlayer.animationState.addAnimation(0, 'Relax', true, 0)
}
}
}, [spinePlayer, voiceOn])
useEffect(() => {
voiceOnRef.current = voiceOn
}, [voiceOn])
const playVoice = useCallback(() => {
if (lastVoiceState === 'ended' && voiceSrc !== null) {
setVoiceReplay(true)
}
}, [voiceSrc])
const handleAduioStateChange = useCallback((e, state) => {
lastVoiceState = state
if (state === 'ended') {
setVoiceReplay(false)
}
}, [])
useEffect(() => {
if (spineRef.current?.children.length === 0) {
setSpinePlayer(
new spine.SpinePlayer(spineRef.current, {
skelUrl: `./_assets/${filename}.skel`,
atlasUrl: `./_assets/${filename}.atlas`,
animation: 'Relax',
premultipliedAlpha: true,
alpha: true,
backgroundColor: '#00000000',
viewport: {
debugRender: false,
padLeft: `${padding.left}%`,
padRight: `${padding.right}%`,
padTop: `${padding.top}%`,
padBottom: `${padding.bottom}%`,
x: 0,
y: 0,
},
showControls: false,
touch: false,
fps: 60,
defaultMix: 0.3,
success: (player) => {
let isPlayingInteract = false
player.animationState.addListener({
end: (e) => {
if (e.animation.name == 'Interact') {
isPlayingInteract = false
}
},
})
setSpineDone(true)
const ani = () => {
if (isPlayingInteract) {
return
}
isPlayingInteract = true
player.animationState.setAnimation(
0,
'Interact',
false,
0
)
player.animationState.addAnimation(
0,
'Relax',
true,
0
)
if (voiceOnRef.current) playVoice()
}
ani()
player.canvas.onclick = () => {
ani()
}
player.canvas.onmouseenter = () => {
ani()
}
},
})
)
}
return () => {
if (spinePlayer) {
spinePlayer.dispose()
}
}
}, [playVoice, spinePlayer])
return (
<section className={classes.error}>
<header className={`${header.header} ${classes.header}`}>
<ReturnButton onClick={() => navigate(-1, { replace: true })} />
<Switch
key="voice"
text="voice"
on={voiceOn}
handleOnClick={() => setVoiceOn(!voiceOn)}
/>
</header>
<main className={classes.main}>
{content.map((item, index) => {
return (
<section key={index} className={classes.content}>
<Typewriter
words={[item]}
cursor
cursorStyle="|"
typeSpeed={100}
/>
</section>
)
})}
<section
className={`${classes.spine} ${spineDone ? classes.active : ''}`}
ref={spineRef}
/>
<VoiceElement
src={voiceSrc}
replay={voiceReplay}
handleAduioStateChange={handleAduioStateChange}
/>
</main>
</section>
)
}

View File

@@ -0,0 +1,307 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import PropTypes from 'prop-types'
import {
Outlet,
Link,
NavLink,
useNavigate,
ScrollRestoration,
} from 'react-router-dom'
import classes from '@/scss/root/Root.module.scss'
import header from '@/scss/root/header.module.scss'
import footer from '@/scss/root/footer.module.scss'
import drawer from '@/scss/root/drawer.module.scss'
import routes from '@/routes'
import { useConfig } from '@/state/config'
import { useHeader } from '@/state/header'
import { useAppbar } from '@/state/appbar'
import { useI18n, useLanguage } from '@/state/language'
import Dropdown from '@/component/dropdown'
import Popup from '@/component/popup'
import Border from '@/component/border'
import CharIcon from '@/component/char_icon'
import ToTopButton from '@/component/totop_button'
const currentYear = new Date().getFullYear()
export default function Root() {
const [drawerHidden, setDrawerHidden] = useState(true)
const { title, tabs, setCurrentTab, headerIcon } = useHeader()
const { extraArea } = useAppbar()
const { fetchOfficialUpdate } = useConfig()
const headerTabs = useMemo(() => {
return tabs?.map((item) => {
return <HeaderTabsElement key={item.key} item={item} />
})
}, [tabs])
const toggleDrawer = useCallback(
(value) => {
setDrawerHidden(value || !drawerHidden)
},
[drawerHidden]
)
useEffect(() => {
if (tabs.length > 0) {
setCurrentTab(tabs[0].key)
} else {
setCurrentTab(null)
}
}, [setCurrentTab, tabs])
useEffect(() => {
fetchOfficialUpdate()
}, [fetchOfficialUpdate])
useEffect(() => {
document.querySelector('.loader').classList.add('loaded')
setTimeout(() => {
document.querySelector('.loader').style.display = 'none'
}, 500)
}, [])
return (
<>
<header className={header.header}>
<section
className={`${header['nav-button']} ${drawerHidden ? '' : header.active}`}
onClick={() => toggleDrawer()}
>
<section className={header.bar} />
<section className={header.bar} />
<section className={header.bar} />
</section>
<HeaderButton />
<section className={header.spacer} />
<section className={header['extra-area']}>
{extraArea}
<LanguageDropdown />
</section>
</header>
<nav
className={`${drawer.drawer} ${drawerHidden ? '' : drawer.active}`}
>
<section className={drawer.links}>
<DrawerDestinations toggleDrawer={toggleDrawer} />
</section>
<section
className={`${drawer.overlay} ${drawerHidden ? '' : drawer.active}`}
onClick={() => toggleDrawer()}
/>
</nav>
<main className={classes.main}>
<section className={classes.header}>
<section className={classes.title}>
{headerIcon && (
<section className={classes.icon}>
<CharIcon
type={headerIcon}
viewBox={
headerIcon === 'operator'
? '0 0 88.969 71.469'
: '0 0 94.563 67.437'
}
/>
</section>
)}
{title}
</section>
<section className={classes.tab}>{headerTabs}</section>
</section>
<Border />
<ToTopButton />
<Outlet />
<ScrollRestoration />
</main>
<FooterElement />
</>
)
}
function FooterElement() {
const { i18n } = useI18n()
const navigate = useNavigate()
return useMemo(() => {
return (
<footer className={footer.footer}>
<section className={`${footer.links} ${footer.section}`}>
<section className={footer.item}>
<Popup
className={footer.link}
title={i18n('disclaimer')}
>
{i18n('disclaimer_content')}
</Popup>
</section>
<section className={footer.item}>
<Link
reloadDocument
to="https://gura.ch/pp"
target="_blank"
className={footer.link}
>
{i18n('privacy_policy')}
</Link>
</section>
<section className={footer.item}>
<Link
reloadDocument
to="https://gura.ch/aklive2d-gh"
target="_blank"
className={footer.link}
>
GitHub
</Link>
</section>
<section className={footer.item}>
<Popup
className={footer.link}
title={i18n('contact_us')}
>
ak#halyul.dev
</Popup>
</section>
</section>
<section
className={`${footer.copyright} ${footer.section}`}
onDoubleClick={() => {
navigate('/error')
}}
>
<span>
Spine Runtimes © 2013 - 2019 Esoteric Software LLC
</span>
<span>
Assets © 2017 - {currentYear} Arknights/Hypergryph Co.,
Ltd
</span>
<span>Source Code © 2021 - {currentYear} Halyul</span>
</section>
</footer>
)
}, [i18n, navigate])
}
function DrawerDestinations({ toggleDrawer }) {
const { i18n } = useI18n()
const { textDefaultLang, alternateLang } = useLanguage()
return routes
.filter((item) => item.inDrawer)
.map((item) => {
if (typeof item.element.type === 'string') {
return (
<Link
reloadDocument
key={item.name}
to={item.path}
target="_blank"
className={drawer.link}
onClick={() => toggleDrawer(false)}
>
<section>{i18n(item.name, textDefaultLang)}</section>
<section>{i18n(item.name, alternateLang)}</section>
</Link>
)
} else {
return (
<NavLink
to={item.path}
key={item.name}
className={({ isActive }) =>
`${drawer.link} ${isActive ? drawer.active : ''}`
}
onClick={() => toggleDrawer(false)}
>
<section>{i18n(item.name, textDefaultLang)}</section>
<section>{i18n(item.name, alternateLang)}</section>
</NavLink>
)
}
})
}
function LanguageDropdown() {
const { language, setLanguage } = useLanguage()
const { i18n, i18nValues } = useI18n()
return useMemo(() => {
return (
<Dropdown
text={i18n(language)}
altText={i18n('switch_language')}
menu={i18nValues.available.map((item) => {
return {
name: i18n(item),
value: item,
}
})}
onClick={(item) => {
setLanguage(item.value)
}}
/>
)
}, [i18n, i18nValues.available, language, setLanguage])
}
function HeaderTabsElement({ item }) {
const { currentTab, setCurrentTab } = useHeader()
const { i18n } = useI18n()
return (
<section
className={`${classes.item} ${currentTab === item.key ? classes.active : ''}`}
onClick={(e) => {
setCurrentTab(item.key)
item.onClick && item.onClick(e, currentTab)
}}
style={item.style}
>
<section className={classes['text-wrapper']}>
<span>{i18n(item.key)}</span>
</section>
</section>
)
}
HeaderTabsElement.propTypes = {
item: PropTypes.object.isRequired,
}
function HeaderButton() {
const navigate = useNavigate()
const { i18n } = useI18n()
const { fastNavigation } = useHeader()
if (fastNavigation.length > 0) {
return (
<Dropdown
menu={fastNavigation}
altText={i18n('fast_navigation')}
onClick={(item) => {
navigate(item.value)
}}
className={header['fast-navigate']}
iconStyle={{
borderWidth: '0.15em',
width: '1em',
transform:
'translateY(-0.4rem) translateX(-0.7rem) rotate(-45deg)',
height: '1em',
}}
left={true}
/>
)
} else {
return (
<section className={header['back-arrow']}>
<Link to="/" className={header.link}>
<section className={header.arrow1} />
<section className={header.arrow2} />
</Link>
</section>
)
}
}

View File

@@ -0,0 +1,29 @@
import Home from '@/routes/path/Home'
import Operator from '@/routes/path/Operator'
export default [
{
path: '/',
index: true,
name: 'home',
element: <Home />,
inDrawer: true,
routeable: true,
},
{
path: 'https://gura.ch/dynamicCompile',
index: false,
name: 'official_page',
element: <a />,
inDrawer: true,
routeable: false,
},
{
path: ':key',
index: false,
name: 'operator',
element: <Operator />,
inDrawer: false,
routeable: true,
},
]

View File

@@ -0,0 +1,451 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import PropTypes from 'prop-types'
import { NavLink, Link } from 'react-router-dom'
import classes from '@/scss/home/Home.module.scss'
import { useConfig } from '@/state/config'
import { useI18n } from '@/state/language'
import { useLanguage } from '@/state/language'
import { useHeader } from '@/state/header'
import { useAppbar } from '@/state/appbar'
import VoiceElement from '@/component/voice'
import { useAtom } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
import CharIcon from '@/component/char_icon'
import Border from '@/component/border'
import useInsight from '@/state/insight'
import Switch from '@/component/switch'
import SearchBox from '@/component/search_box'
import buildConfig from '!/config.json'
const voiceOnAtom = atomWithStorage('voiceOn', false)
let lastVoiceState = 'ended'
export default function Home() {
// eslint-disable-next-line no-unused-vars
const _trackEvt = useInsight()
const { setTitle, setTabs, currentTab, setHeaderIcon, setFastNavigation } =
useHeader()
const { config, operators, officialUpdate } = useConfig()
const { i18n } = useI18n()
const [content, setContent] = useState([])
const [voiceOn] = useAtom(voiceOnAtom)
const [voiceSrc, setVoiceSrc] = useState(null)
const [voiceReplay, setVoiceReplay] = useState(false)
const { language } = useLanguage()
const [navigationList, setNavigationList] = useState([])
const [searchField, setSearchField] = useState('')
const [updatedList, setUpdatedList] = useState([])
useEffect(() => {
setTitle('dynamic_compile')
setTabs([
{
key: 'all',
},
{
key: 'operator',
},
{
key: 'skin',
},
])
setHeaderIcon(null)
}, [setHeaderIcon, setTabs, setTitle])
useEffect(() => {
setContent(config?.operators || [])
}, [config])
const handleAduioStateChange = useCallback((e, state) => {
lastVoiceState = state
if (state === 'ended') {
setVoiceReplay(false)
}
}, [])
const isShown = useCallback(
(type) => currentTab === 'all' || currentTab === type,
[currentTab]
)
const fastNavigateDict = useMemo(() => {
const dict = {}
operators.forEach((item) => {
if (!(item.date in dict)) {
dict[item.date] = []
}
dict[item.date].push({
codename: item.codename,
link: item.link,
type: item.type,
color: item.color,
})
})
return dict
}, [operators])
useEffect(() => {
const list = []
for (const [key, value] of Object.entries(fastNavigateDict)) {
const newValue = value.filter((item) => isShown(item.type))
if (newValue.length > 0) {
list.push({
name: key,
value: null,
type: 'date',
})
newValue.forEach((item) => {
list.push({
name: item.codename[language],
value: item.link,
type: 'item',
color: item.color,
icon: (
<CharIcon
type={item.type}
viewBox={
item.type === 'operator'
? '0 0 88.969 71.469'
: '0 0 94.563 67.437'
}
/>
),
})
})
}
}
setNavigationList(list)
setUpdatedList(list)
}, [fastNavigateDict, isShown, language])
useEffect(() => {
const list = navigationList.filter((item) => {
return (
item.name.toLowerCase().indexOf(searchField.toLowerCase()) !==
-1 || item.type === 'date'
)
})
const newList = []
for (let i = 0; i < list.length - 1; i++) {
const firstType = list[i].type
const secondType = list[i + 1].type
if (firstType === 'date' && secondType === 'date') {
continue
}
newList.push(list[i])
}
if (list.length > 0 && list[list.length - 1].type !== 'date') {
newList.push(list[list.length - 1])
}
setUpdatedList(newList)
}, [navigationList, searchField])
useEffect(() => {
setFastNavigation([
{
type: 'custom',
component: (
<SearchBox
key="search-box"
altText={'search_by_name'}
handleOnChange={(e) => {
setSearchField(e)
}}
searchField={searchField}
/>
),
},
...updatedList,
])
}, [searchField, setFastNavigation, updatedList])
const handleVoicePlay = useCallback(
(src) => {
if (!voiceOn) {
setVoiceSrc(null)
} else {
if (src === voiceSrc && lastVoiceState === 'ended') {
setVoiceReplay(true)
} else {
setVoiceSrc(src)
}
}
},
[voiceOn, voiceSrc]
)
return (
<section>
{officialUpdate.length > operators.length && (
<section>
<section
className={`${classes['official-update']} ${classes.group}`}
>
<section className={classes.info}>
<section className={classes.content}>
<section className={classes.text}>
{officialUpdate.length - operators.length}{' '}
{i18n('new_op_wait_to_update')}
</section>
<section
className={`${classes['styled-selection']}`}
>
{officialUpdate.dates
.reduce((acc, cur) => {
const op = officialUpdate[cur]
return [...acc, ...op]
}, [])
.slice(
0,
officialUpdate.length -
operators.length
)
.map((entry, index) => {
return (
<Link
reloadDocument
to={entry.link}
target="_blank"
style={{
color: entry.color,
}}
key={index}
>
<section
className={
classes.content
}
>
<section
className={
classes.option
}
>
<section
className={
classes.outline
}
/>
<section
className={`${classes.text} ${classes.container}`}
>
<section
className={
classes.type
}
>
<CharIcon
type={
entry.type
}
viewBox={
entry.type ===
'operator'
? '0 0 88.969 71.469'
: '0 0 94.563 67.437'
}
/>
</section>
<section
className={
classes.title
}
>
{
entry
.codename[
language
]
}
</section>
<section
className={
classes[
'arrow-icon'
]
}
>
<section
className={
classes.bar
}
></section>
<section
className={
classes.bar
}
></section>
<section
className={
classes.bar
}
></section>
<section
className={
classes.bar
}
></section>
</section>
</section>
</section>
</section>
</Link>
)
})}
</section>
</section>
</section>
<section className={classes.date}>
{officialUpdate.latest}
</section>
</section>
<Border />
</section>
)}
{content.map((v) => {
const length = v.filter((v) => isShown(v.type)).length
return (
<section key={v[0].date} hidden={length === 0}>
<section className={classes.group}>
<section className={classes['operator-group']}>
{v.map((item) => {
return (
<OperatorElement
key={item.link}
item={item}
hidden={!isShown(item.type)}
handleVoicePlay={handleVoicePlay}
/>
)
})}
</section>
<section className={classes.date}>
{v[0].date}
</section>
</section>
<Border />
</section>
)
})}
<VoiceSwitchElement
src={voiceSrc}
handleAduioStateChange={handleAduioStateChange}
replay={voiceReplay}
/>
</section>
)
}
function OperatorElement({ item, hidden, handleVoicePlay }) {
const { textDefaultLang, language, alternateLang } = useLanguage()
return useMemo(() => {
return (
<NavLink
to={`/${item.link}`}
className={classes.item}
hidden={hidden}
>
<section
onMouseEnter={() =>
handleVoicePlay(
`/${item.link}/assets/${buildConfig.voice_folders.main}/${buildConfig.app_voice_url}`
)
}
>
<section className={classes['background-filler']} />
<section className={classes.outline} />
<section className={classes.img}>
<ImageElement item={item} />
</section>
<section className={classes.info}>
<section className={classes.container}>
<section className={classes.title}>
{item.codename[language]}
</section>
<section className={classes.type}>
<CharIcon
type={item.type}
viewBox={
item.type === 'operator'
? '0 0 88.969 71.469'
: '0 0 94.563 67.437'
}
/>
</section>
</section>
<section className={classes.wrapper}>
<span className={classes.text}>
{
item.codename[
language.startsWith('en')
? alternateLang
: textDefaultLang
]
}
</span>
</section>
<section
className={classes.background}
style={{
color: item.color,
}}
/>
</section>
</section>
</NavLink>
)
}, [
item,
hidden,
language,
alternateLang,
textDefaultLang,
handleVoicePlay,
])
}
function VoiceSwitchElement({ src, replay, handleAduioStateChange }) {
const [voiceOn, setVoiceOn] = useAtom(voiceOnAtom)
const { setExtraArea } = useAppbar()
useEffect(() => {
setExtraArea([
<Switch
key="voice"
text="voice"
on={voiceOn}
handleOnClick={() => setVoiceOn(!voiceOn)}
/>,
])
}, [voiceOn, setExtraArea, setVoiceOn])
return (
<VoiceElement
src={src}
replay={replay}
handleAduioStateChange={handleAduioStateChange}
/>
)
}
VoiceSwitchElement.propTypes = {
src: PropTypes.string,
replay: PropTypes.bool,
handleAduioStateChange: PropTypes.func,
}
function ImageElement({ item }) {
const { language } = useLanguage()
return (
<img
src={`/${buildConfig.directory_folder}/${item.fallback_name.replace(/#/g, '%23')}_portrait.png`}
alt={item.codename[language]}
/>
)
}
ImageElement.propTypes = {
item: PropTypes.object.isRequired,
fallback_name: PropTypes.string,
codename: PropTypes.object,
}

View File

@@ -0,0 +1,694 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import classes from '@/scss/operator/Operator.module.scss'
import { useConfig } from '@/state/config'
import { useLanguage } from '@/state/language'
import { useHeader } from '@/state/header'
import { useAppbar } from '@/state/appbar'
import VoiceElement from '@/component/voice'
import useInsight from '@/state/insight'
import { spine } from '@aklive2d/module'
import Border from '@/component/border'
import { useI18n } from '@/state/language'
import Switch from '@/component/switch'
import { atom, useAtom } from 'jotai'
import buildConfig from '!/config.json'
const musicMapping = buildConfig.music_mapping
const getVoiceFoler = (lang) => {
const folderObject = buildConfig.voice_folders
const voiceFolder =
folderObject.sub.find((e) => e.lang === lang) ||
folderObject.sub.find((e) => e.name === 'custom')
return `${folderObject.main}/${voiceFolder.name}`
}
const defaultSpineAnimationName = 'Idle'
const backgroundAtom = atom(buildConfig.default_background)
const getPartialName = (type, input) => {
let part
switch (type) {
case 'name':
part = 5
break
case 'skin':
part = 1
break
default:
return input
}
return input.replace(/^(.+)( )(·|\/)( )(.+)$/, `$${part}`)
}
const getTabName = (item, language) => {
if (item.type === 'operator') {
return 'operator'
} else {
return getPartialName('skin', item.codename[language])
}
}
export default function Operator() {
const navigate = useNavigate()
const { operators } = useConfig()
const { language } = useLanguage()
const { key } = useParams()
const { setTitle, setTabs, setHeaderIcon, setFastNavigation } = useHeader()
const { setExtraArea } = useAppbar()
const [config, setConfig] = useState(null)
// eslint-disable-next-line no-unused-vars
const _trackEvt = useInsight(`/${key}`)
const spineRef = useRef(null)
const [spineAnimationName, setSpineAnimationName] = useState(
defaultSpineAnimationName
)
const { i18n } = useI18n()
const [spinePlayer, setSpinePlayer] = useState(null)
const [voiceLang, _setVoiceLang] = useState(null)
const [currentBackground, setCurrentBackground] = useAtom(backgroundAtom)
const [voiceConfig, setVoiceConfig] = useState(null)
const [subtitleLang, setSubtitleLang] = useState(null)
const [hideSubtitle, setHideSubtitle] = useState(true)
const [subtitleObj, _setSubtitleObj] = useState(null)
const [currentVoiceId, setCurrentVoiceId] = useState(null)
const voiceLangRef = useRef(voiceLang)
const subtitleObjRef = useRef(subtitleObj)
const configRef = useRef(config)
const [voiceSrc, setVoiceSrc] = useState(null)
const [isVoicePlaying, _setIsVoicePlaying] = useState(false)
const isVoicePlayingRef = useRef(isVoicePlaying)
const setVoiceLang = (value) => {
voiceLangRef.current = value
_setVoiceLang(value)
}
const setSubtitleObj = (value) => {
subtitleObjRef.current = value
_setSubtitleObj(value)
}
const setIsVoicePlaying = (value) => {
isVoicePlayingRef.current = value
_setIsVoicePlaying(value)
}
useEffect(() => {
setExtraArea([])
setFastNavigation([])
}, [setExtraArea, setFastNavigation])
useEffect(() => {
const config = operators.find((item) => item.link === key)
if (config) {
setConfig(config)
configRef.current = config
setSpineAnimationName(defaultSpineAnimationName)
setHeaderIcon(config.type)
if (spineRef.current?.children.length > 0) {
spineRef.current?.removeChild(spineRef.current?.children[0])
}
fetch(`/${key}/assets/charword_table.json`)
.then((res) => res.json())
.then((data) => {
setVoiceConfig(data)
})
}
}, [key, operators, setHeaderIcon])
const coverToTab = useCallback(
(item, language) => {
const key = getTabName(item, language)
return {
key: key,
style: {
color: item.color,
},
onClick: (e, tab) => {
if (tab === key) return
navigate(`/${item.link}`)
},
}
},
[navigate]
)
const otherEntries = useMemo(() => {
if (!config || !language) return null
return operators
.filter(
(item) => item.id === config.id && item.link !== config.link
)
.map((item) => {
return coverToTab(item, language)
})
}, [config, language, operators, coverToTab])
useEffect(() => {
if (config) {
setTabs([coverToTab(config, language), ...otherEntries])
}
}, [config, key, coverToTab, setTabs, otherEntries, language])
useEffect(() => {
if (config) {
setTitle(getPartialName('name', config.codename[language]))
}
}, [config, language, key, setTitle])
useEffect(() => {
if (spineRef.current?.children.length === 0 && configRef.current) {
const playerConfig = {
atlasUrl: `./${key}/assets/${configRef.current.filename.replace(/#/g, '%23')}.atlas`,
animation: spineAnimationName,
premultipliedAlpha: true,
alpha: true,
backgroundColor: '#00000000',
viewport: {
debugRender: false,
padLeft: `${configRef.current.viewport_left}%`,
padRight: `${configRef.current.viewport_right}%`,
padTop: `${configRef.current.viewport_top}%`,
padBottom: `${configRef.current.viewport_bottom}%`,
x: 0,
y: 0,
},
showControls: false,
touch: false,
fps: 60,
defaultMix: 0.3,
success: (player) => {
if (
player.skeleton.data.animations
.map((e) => e.name)
.includes('Start')
) {
player.animationState.setAnimation(0, 'Start', false, 0)
player.animationState.addAnimation(0, 'Idle', true, 0)
}
let lastVoiceId = null
let currentVoiceId = null
player.canvas.onclick = () => {
if (!voiceLangRef.current) return
const voiceId = () => {
const keys = Object.keys(subtitleObjRef.current)
const id =
keys[Math.floor(Math.random() * keys.length)]
return id === lastVoiceId ? voiceId() : id
}
const id = voiceId()
currentVoiceId = id
setCurrentVoiceId(id)
setVoiceSrc(
`/${configRef.current.link}/assets/${getVoiceFoler(voiceLangRef.current)}/${id}.ogg`
)
lastVoiceId = currentVoiceId
}
},
}
if (configRef.current.use_json) {
playerConfig.jsonUrl = `./${key}/assets/${configRef.current.filename.replace(/#/g, '%23')}.json`
} else {
playerConfig.skelUrl = `./${key}/assets/${configRef.current.filename.replace(/#/g, '%23')}.skel`
}
setSpinePlayer(
new spine.SpinePlayer(spineRef.current, playerConfig)
)
}
}, [spineAnimationName, setSpinePlayer, spinePlayer, key])
useEffect(() => {
return () => {
if (spinePlayer) {
spinePlayer.dispose()
}
}
}, [spinePlayer])
useEffect(() => {
if (voiceConfig && voiceLang) {
let subtitleObj = voiceConfig.subtitleLangs[subtitleLang || 'zh-CN']
let subtitleKey = 'default'
if (subtitleObj[voiceLang]) {
subtitleKey = voiceLang
}
setSubtitleObj(subtitleObj[subtitleKey])
}
}, [subtitleLang, voiceConfig, voiceLang])
const handleAduioStateChange = useCallback((e, state) => {
switch (state) {
case 'play':
setIsVoicePlaying(true)
break
default:
setIsVoicePlaying(false)
break
}
}, [])
useEffect(() => {
if (subtitleLang) {
if (isVoicePlaying) {
setHideSubtitle(false)
} else {
const autoHide = () => {
if (isVoicePlayingRef.current) return
setHideSubtitle(true)
}
setTimeout(autoHide, 5 * 1000)
return () => {
clearTimeout(autoHide)
}
}
} else {
setHideSubtitle(true)
}
}, [subtitleLang, isVoicePlaying])
useEffect(() => {
if (voiceLang && isVoicePlaying) {
const audioUrl = `/assets/${getVoiceFoler(voiceLang)}/${currentVoiceId}.ogg`
if (
voiceSrc !==
window.location.href.replace(/\/$/g, '') + audioUrl
) {
setVoiceSrc(`/${config.link}${audioUrl}`)
}
}
}, [voiceLang, isVoicePlaying, currentVoiceId, config, voiceSrc])
const playAnimationVoice = useCallback(
(animation) => {
if (voiceLangRef.current) {
let id = null
if (animation === 'Idle') id = 'CN_011'
if (animation === 'Interact') id = 'CN_034'
if (animation === 'Special') id = 'CN_042'
if (id) {
setCurrentVoiceId(id)
setVoiceSrc(
`/${key}/assets/${getVoiceFoler(voiceLangRef.current)}/${id}.ogg`
)
}
}
},
[key]
)
useEffect(() => {
if (!voiceLang) {
setVoiceSrc(null)
}
}, [voiceLang])
const setSpineAnimation = useCallback(
(animation) => {
playAnimationVoice(animation)
const entry = spinePlayer.animationState.setAnimation(
0,
animation,
true
)
entry.mixDuration = 0.3
setSpineAnimationName(animation)
},
[playAnimationVoice, spinePlayer]
)
const spineSettings = [
{
name: 'animation',
options: [
{
name: 'idle',
onClick: () => setSpineAnimation('Idle'),
activeRule: () => {
return spineAnimationName === 'Idle'
},
},
{
name: 'interact',
onClick: () => setSpineAnimation('Interact'),
activeRule: () => {
return spineAnimationName === 'Interact'
},
},
{
name: 'special',
onClick: () => setSpineAnimation('Special'),
activeRule: () => {
return spineAnimationName === 'Special'
},
},
],
},
{
name: 'voice',
options:
(voiceConfig &&
Object.keys(voiceConfig?.voiceLangs['zh-CN']).map(
(item) => {
return {
name: i18n(item),
onClick: () => {
if (voiceLang !== item) {
setVoiceLang(item)
} else {
setVoiceLang(null)
}
if (!isVoicePlayingRef.current) {
playAnimationVoice(spineAnimationName)
}
},
activeRule: () => {
return voiceLang === item
},
}
}
)) ||
[],
},
{
name: 'subtitle',
options:
(voiceConfig &&
Object.keys(voiceConfig?.subtitleLangs).map((item) => {
return {
name: i18n(item),
onClick: () => {
if (subtitleLang !== item) {
setSubtitleLang(item)
} else {
setSubtitleLang(null)
}
},
activeRule: () => {
return subtitleLang === item
},
}
})) ||
[],
},
{
name: 'music',
el: <MusicElement />,
},
{
name: 'backgrounds',
options:
buildConfig.background_files.map((item) => {
return {
name: item,
onClick: () => {
setCurrentBackground(item)
},
activeRule: () => {
return currentBackground === item
},
}
}) || [],
},
]
if (!buildConfig.available_operators.includes(key)) {
throw new Error('Operator not found')
}
return (
<section className={classes.operator}>
<section className={classes.main}>
<section
className={classes.settings}
style={{
color: config?.color,
}}
>
{spineSettings.map((item) => {
if (item.el) {
return <section key={item.name}>{item.el}</section>
}
if (item.options.length === 0) return null
return (
<section key={item.name}>
<section className={classes.title}>
<section className={classes.text}>
{i18n(item.name)}
</section>
</section>
<section
className={classes['styled-selection']}
>
{item.options.map((option) => {
return (
<section
className={`${classes.content} ${option.activeRule && option.activeRule() ? classes.active : ''}`}
onClick={(e) =>
option.onClick(e)
}
key={option.name}
>
<section
className={classes.option}
>
<section
className={
classes.outline
}
/>
<section
className={`${classes.text} ${classes['no-overflow']}`}
>
{i18n(option.name)}
</section>
<section
className={
classes['tick-icon']
}
/>
</section>
</section>
)
})}
</section>
</section>
)
})}
<section>
<section className={classes.title}>
<section className={classes.text}>
{i18n('external_links')}
</section>
</section>
<section className={classes['styled-selection']}>
<Link
reloadDocument
to={`./index.html?aklive2d`}
target="_blank"
style={{
color: config?.color,
}}
>
<section className={classes.content}>
<section className={classes.option}>
<section className={classes.outline} />
<section
className={`${classes.text} ${classes['no-overflow']}`}
>
{i18n('web_version')}
</section>
<section
className={classes['arrow-icon']}
>
<section
className={classes.bar}
></section>
<section
className={classes.bar}
></section>
<section
className={classes.bar}
></section>
<section
className={classes.bar}
></section>
</section>
</section>
</section>
</Link>
{config?.workshopId && (
<Link
reloadDocument
to={`https://steamcommunity.com/sharedfiles/filedetails/?id=${config.workshopId}`}
target="_blank"
style={{
color: config?.color,
}}
>
<section className={classes.content}>
<section className={classes.option}>
<section
className={classes.outline}
/>
<section
className={`${classes.text} ${classes['no-overflow']}`}
>
{i18n('steam_workshop')}
</section>
<section
className={
classes['arrow-icon']
}
>
<section
className={classes.bar}
></section>
<section
className={classes.bar}
></section>
<section
className={classes.bar}
></section>
<section
className={classes.bar}
></section>
</section>
</section>
</section>
</Link>
)}
</section>
</section>
</section>
<section
className={classes.container}
style={
currentBackground && {
backgroundImage: `url(/chen/assets/${buildConfig.background_folder}/${currentBackground})`,
}
}
>
{config && (
<img
src={`/${config.link}/assets/${config.logo}.png`}
alt={config?.codename[language]}
className={classes.logo}
style={
config.invert_filter
? {
filter: 'invert(1)',
}
: {}
}
/>
)}
<section ref={spineRef} className={classes.wrapper} />
{currentVoiceId && subtitleObj && (
<section
className={`${classes.voice} ${hideSubtitle ? '' : classes.active}`}
>
<section className={classes.type}>
{subtitleObj[currentVoiceId]?.title}
</section>
<section className={classes.subtitle}>
<span>{subtitleObj[currentVoiceId]?.text}</span>
<span className={classes.triangle} />
</section>
</section>
)}
</section>
</section>
<Border />
<VoiceElement
src={voiceSrc}
handleAduioStateChange={handleAduioStateChange}
/>
</section>
)
}
function MusicElement() {
const [enableMusic, setEnableMusic] = useState(false)
const { i18n } = useI18n()
const musicIntroRef = useRef(null)
const musicLoopRef = useRef(null)
const [background] = useAtom(backgroundAtom)
useEffect(() => {
if (musicIntroRef.current && musicIntroRef.current) {
musicIntroRef.current.volume = 0.5
musicLoopRef.current.volume = 0.5
}
}, [musicIntroRef, musicLoopRef])
useEffect(() => {
if (!enableMusic || background) {
musicIntroRef.current.pause()
musicLoopRef.current.pause()
}
}, [enableMusic, background])
useEffect(() => {
if (background && enableMusic) {
const introOgg = musicMapping[background].intro
const intro = `./chen/assets/${buildConfig.music_folder}/${introOgg}`
const loop = `./chen/assets/${buildConfig.music_folder}/${musicMapping[background].loop}`
musicLoopRef.current.src = loop
if (introOgg) {
musicIntroRef.current.src = intro || loop
} else {
musicLoopRef.current.play()
}
}
}, [background, enableMusic])
const handleIntroTimeUpdate = useCallback(() => {
if (
musicIntroRef.current.currentTime >=
musicIntroRef.current.duration - 0.3
) {
musicIntroRef.current.pause()
musicLoopRef.current.play()
}
}, [])
const handleLoopTimeUpdate = useCallback(() => {
if (
musicLoopRef.current.currentTime >=
musicLoopRef.current.duration - 0.3
) {
musicLoopRef.current.currentTime = 0
musicLoopRef.current.play()
}
}, [])
return (
<section>
<section
className={classes.title}
onClick={() => setEnableMusic(!enableMusic)}
>
<section className={classes.text}>{i18n('music')}</section>
<section className={classes.switch}>
<Switch on={enableMusic} />
</section>
</section>
<audio
ref={musicIntroRef}
preload="auto"
autoPlay
onTimeUpdate={() => handleIntroTimeUpdate()}
>
<source type="audio/ogg" />
</audio>
<audio
ref={musicLoopRef}
preload="auto"
onTimeUpdate={() => handleLoopTimeUpdate()}
>
<source type="audio/ogg" />
</audio>
</section>
)
}

View File

@@ -0,0 +1,20 @@
.main {
flex-grow: 1;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: flex-start;
padding-bottom: 3rem;
margin: 0 auto;
width: 70%;
max-width: 100rem;
padding-top: 5rem;
min-height: calc(100vh - 5rem - 3rem);
@media only screen and (width <= 430px) {
& {
width: 100vw;
margin-left: 0;
}
}
}

View File

@@ -0,0 +1,369 @@
.date {
margin: 1.5rem;
font-family:
Bender, 'Noto Sans SC', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans',
sans-serif;
font-weight: bold;
text-align: right;
color: var(--date-color);
font-size: 1.5rem;
letter-spacing: 0.1rem;
flex: auto;
user-select: none;
@media only screen and (width <= 430px) {
& {
margin: 0;
}
}
}
.container {
color: var(--text-color-full);
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 1.25rem;
font-weight: bold;
.title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3em;
height: auto;
}
.type {
display: flex;
flex-direction: row;
align-items: center;
svg {
width: 1.5rem;
fill: var(--text-color);
}
}
}
.group {
padding: 1rem;
display: flex;
align-items: flex-end;
flex-wrap: wrap;
user-select: none;
.operator-group {
display: flex;
flex-flow: row wrap;
@media only screen and (width <= 430px) {
& {
width: 100%;
padding-bottom: 1rem;
overflow-y: hidden;
flex-wrap: nowrap;
}
}
}
.item {
position: relative;
flex-shrink: 0;
cursor: pointer;
width: 12rem;
margin: 1.25rem;
background-image: repeating-linear-gradient(
90deg,
var(--home-item-background-linear-gradient-color) 0,
var(--home-item-background-linear-gradient-color) 1px,
transparent 1px,
transparent 5px
);
.background-filler {
border-right: 1px solid
var(--home-item-background-linear-gradient-color);
position: absolute;
inset: 0 -1px 0 0;
}
.outline {
display: block;
position: absolute;
opacity: 0;
visibility: hidden;
transition: all cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
width: 100%;
height: 100%;
left: -6px;
top: -6px;
border: var(--home-item-outline-color) 1px dashed;
padding: 6px;
&::before,
&::after {
content: '';
display: block;
position: absolute;
left: -3px;
height: 3px;
width: 100%;
border-left: var(--text-color) solid 3px;
border-right: var(--text-color) solid 3px;
}
&::before {
top: -3px;
}
&::after {
bottom: -3px;
}
}
.img {
transition: background-color cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
& img {
height: auto;
width: 100%;
display: block;
}
}
.info {
white-space: nowrap;
position: relative;
padding: 0.8rem 0.4rem;
line-height: 1.2em;
height: 36px;
.wrapper {
overflow: hidden;
text-overflow: ellipsis;
color: var(--secondary-text-color);
.text {
font-size: 0.75rem;
font-family:
Geometos, 'Noto Sans SC', 'Noto Sans JP',
'Noto Sans KR', 'Noto Sans', sans-serif;
margin-top: 1rem;
}
}
.background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
visibility: hidden;
transition: all cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
background-image: linear-gradient(
70deg,
transparent 40%,
currentcolor 150%
);
}
}
&:hover {
.img {
background-color: var(--home-item-hover-background-color);
}
.outline,
.info .background {
opacity: 1;
visibility: visible;
}
}
@media only screen and (width <= 430px) {
& {
width: 8.08rem;
margin: 1.08rem;
.outline,
.info .background {
opacity: 1;
visibility: visible;
}
}
}
}
@media only screen and (width <= 430px) {
& {
align-items: flex-start;
flex-direction: column-reverse;
padding-bottom: 0;
}
}
}
.styled-selection {
margin-bottom: 0.8rem;
.content {
padding: 0.8rem 0;
cursor: pointer;
transition: transform cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
.option {
white-space: nowrap;
pointer-events: none;
position: relative;
transform: translate3d(0, 0, 1px);
font-size: 1rem;
padding: 0.44rem 3.25rem 0.44rem 0.63rem;
background-color: var(--home-item-hover-background-color);
background-image: repeating-linear-gradient(
90deg,
var(--home-item-background-linear-gradient-color) 0 1px,
transparent 1px 4px
);
transition: transform cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
.outline {
width: 100%;
height: 100%;
left: -6px;
top: -6px;
border: var(--home-item-outline-color) 1px dashed;
padding: 6px;
&::before,
&::after {
content: '';
display: block;
position: absolute;
left: -2px;
height: 2px;
width: 100%;
border-left: var(--text-color) solid 2px;
border-right: var(--text-color) solid 2px;
}
&::before {
top: -2px;
}
&::after {
bottom: -2px;
}
}
&::before,
.outline {
content: '';
display: block;
position: absolute;
z-index: -1;
opacity: 0;
visibility: hidden;
transition:
opacity cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s,
visibility cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
}
&::before {
right: 0;
top: 0;
width: 60%;
height: 100%;
background-image: linear-gradient(
90deg,
transparent,
currentcolor
);
}
.tick-icon {
display: inline-block;
position: absolute;
z-index: 0;
right: 0.31rem;
top: 50%;
width: 0.5rem;
height: 1rem;
opacity: 0;
visibility: hidden;
transition:
opacity cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s,
visibility cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
border-right: var(--text-color) solid 0.25rem;
border-bottom: var(--text-color) solid 0.25rem;
transform: translate(-50%, -70%) rotate(45deg);
}
.arrow-icon {
display: inline-block;
position: absolute;
z-index: 0;
right: -0.1em;
top: 0.55em;
width: 2em;
height: 1em;
transform: rotate(90deg);
transition:
opacity cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s,
visibility cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
opacity: 0;
visibility: hidden;
.bar {
position: absolute;
width: 100%;
height: 0.25em;
background-color: var(--text-color-full);
will-change: auto;
}
.bar:nth-child(1) {
transform: rotateZ(45deg) scaleX(0.5) translateX(0.8em);
}
.bar:nth-child(2) {
transform: rotateZ(-45deg) scaleX(0.5) translateX(-0.8em);
}
.bar:nth-child(3),
.bar:nth-child(4) {
transform: translateY(1em) rotateZ(90deg) scaleX(0.5);
}
}
}
&:hover,
&.active {
transform: translate3d(6px, 0, 1px);
.option::before,
.option .outline {
opacity: 1;
visibility: visible;
}
.arrow-icon {
opacity: 1;
visibility: visible;
}
}
&.active {
.option .tick-icon {
opacity: 1;
visibility: visible;
}
}
}
}
.no-overflow {
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -0,0 +1,30 @@
@use '@/scss/page_base';
.group {
flex-direction: column;
align-items: stretch;
.info {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding-left: 1rem;
word-break: break-word;
.content {
font-size: 1.5rem;
display: list-item;
}
}
.date {
margin-bottom: 1rem;
}
@media only screen and (width <= 430px) {
flex-direction: column-reverse;
align-items: flex-start;
padding-bottom: 1rem;
}
}

View File

@@ -0,0 +1,61 @@
@use '@/scss/main_share';
.error {
min-height: 100vh;
display: flex;
flex-direction: column;
font-size: 16px;
user-select: none;
.header {
padding: 1rem;
justify-content: space-between;
pointer-events: auto;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
padding-top: 6rem;
font-size: 3rem;
gap: 2rem;
padding-bottom: 0;
}
.spine {
max-width: 600px;
flex: 1;
visibility: hidden;
opacity: 0;
&.active {
visibility: visible;
opacity: 1;
}
}
@media (width <= 768px) {
.main {
padding-top: 6rem;
max-height: calc(100vh - 6rem);
}
.content {
font-size: 2rem;
}
}
@media (width <= 480px) {
.main {
padding-top: 4rem;
max-height: calc(100vh - 4rem);
}
.content {
font-size: 1.5rem;
}
}
}

View File

@@ -0,0 +1,23 @@
@use '@/scss/page_base';
.official-update {
.info {
display: flex;
flex-direction: column;
align-items: flex-start;
padding-left: 1rem;
word-break: break-word;
margin-top: 1.25rem;
.content {
font-size: 1.5rem;
max-width: 100%;
}
.container {
.title {
margin-left: 0.5rem;
}
}
}
}

View File

@@ -0,0 +1,142 @@
@use '@/scss/page_base';
.main {
padding: 3rem 0 2rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
.container {
background-position: center;
background-repeat: no-repeat;
background-size: cover;
width: 100%;
position: relative;
margin-bottom: 2rem;
user-select: none;
&::before {
content: '';
display: block;
padding-top: 100%;
}
.wrapper {
position: absolute;
inset: 0;
}
@media (width <= 1280px) {
width: 100%;
position: relative;
margin-bottom: 2rem;
&::before {
content: '';
display: block;
padding-top: 100%;
}
}
}
.settings {
margin-right: 1.5rem;
user-select: none;
margin-left: 1rem;
.title {
font-size: 1.25rem;
border-left: 3px solid currentcolor;
padding-left: 0.75rem;
margin-bottom: 0.8rem;
display: flex;
justify-content: space-between;
cursor: pointer;
.switch {
bottom: -10px;
position: relative;
}
}
}
.text {
color: var(--text-color);
line-height: 1.2;
}
.logo {
position: absolute;
top: 0;
left: 0;
width: 30%;
height: auto;
opacity: 0.3;
}
.voice {
position: absolute;
left: 0;
bottom: 20%;
z-index: 1;
max-width: 480px;
width: 85%;
opacity: 0;
margin: 16px;
transition: all 0.5s cubic-bezier(0.65, 0.05, 0.36, 1);
visibility: hidden;
font-family:
'Noto Sans SC', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans',
sans-serif;
.type {
background-color: #9e9e9e;
color: #000;
display: inline-block;
position: absolute;
top: -12px;
left: -8px;
padding: 2px 8px;
font-size: 14px;
max-width: 180px;
width: 65%;
box-shadow: 0 3px 6px #00000080;
z-index: 1;
}
.subtitle {
background-color: #000000a6;
color: #fff;
padding: 16px;
font-size: 18px;
box-shadow: 0 6px 12px #00000080;
position: relative;
word-break: break-word;
}
.triangle {
position: absolute;
bottom: 0;
right: 8px;
width: 0;
height: 0;
border-style: solid;
border-width: 8px;
border-color: white transparent transparent;
}
&.active {
opacity: 1;
visibility: visible;
}
@media (width <= 1280px) {
bottom: 0;
}
}
@media (width <= 1280px) {
flex-direction: column-reverse;
align-items: stretch;
}
}

View File

@@ -0,0 +1,95 @@
@use '@/scss/main_share';
.main {
.header {
display: flex;
flex-flow: row wrap;
justify-content: space-between;
align-items: flex-end;
position: relative;
@media only screen and (width <= 430px) {
& {
margin: 0 1rem;
}
}
.title {
font-size: 3rem;
font-weight: 700;
text-transform: uppercase;
line-height: 1.2em;
.icon {
margin-right: 1.5rem;
display: inline-block;
vertical-align: middle;
svg {
fill: var(--text-color);
height: 2.5rem;
}
}
@media (width <= 600px) {
font-size: 2.5rem;
}
@media (width <= 480px) {
font-size: 2rem;
}
}
.tab {
flex: auto;
white-space: pre;
user-select: none;
display: flex;
flex-direction: row;
justify-content: flex-end;
overflow: hidden;
z-index: 1;
.item {
font-size: 1.25rem;
line-height: 3em;
font-weight: 700;
padding: 0 1rem;
text-transform: uppercase;
border-bottom: 0.3rem solid transparent;
display: inline-block;
cursor: pointer;
text-decoration: none;
.text-wrapper {
color: var(--text-color);
transition: color cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
}
&.active,
&:hover {
color: var(--link-highlight-color);
.text-wrapper,
.text {
color: currentcolor;
}
}
&.active {
border-bottom-color: currentcolor;
}
@media only screen and (width <= 430px) {
line-height: 2em;
}
}
}
@media only screen and (width <= 430px) {
& {
padding-right: 0;
}
}
}
}

View File

@@ -0,0 +1,55 @@
.drawer {
position: fixed;
top: 0;
left: -15rem;
width: 15rem;
height: 100%;
z-index: 3;
pointer-events: none;
transition: left cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
text-align: center;
display: flex;
flex-direction: row;
align-items: flex-start;
user-select: none;
.links {
padding: 8rem 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
background-color: var(--drawer-background-color);
height: 100%;
width: 15rem;
.link {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
color: var(--text-color);
font-size: 0.8rem;
font-weight: 500;
transition: color cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
text-transform: uppercase;
&:hover,
&.active {
color: var(--link-highlight-color);
}
}
}
.overlay {
height: 100%;
flex-grow: 1;
z-index: 3;
}
&.active {
pointer-events: all;
left: 0;
width: 100vw;
}
}

View File

@@ -0,0 +1,42 @@
.footer {
user-select: none;
.section {
border-top: 1px solid var(--border-color);
padding: 1rem 0;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: nowrap;
font-family:
'Noto Sans SC', 'Noto Sans JP', 'Noto Sans KR', 'Noto Sans',
sans-serif;
}
.links {
flex-direction: row;
height: 2rem;
overflow-y: hidden;
.item {
text-align: center;
padding: 0 1rem;
border-left: 2px solid var(--border-color);
height: inherit;
display: flex;
flex-flow: column wrap;
justify-content: center;
align-items: center;
&:first-of-type {
border-left: none;
}
}
}
.copyright {
flex-direction: column;
gap: 0.5rem;
font-size: 12px;
}
}

View File

@@ -0,0 +1,106 @@
.header {
width: auto;
position: fixed;
left: 0;
top: 0;
right: 0;
padding: 1rem;
z-index: 4;
height: 3rem;
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: flex-start;
pointer-events: none;
.fast-navigate {
pointer-events: auto;
margin-left: 0.6rem;
height: 2rem;
width: 2rem;
}
.dropdown {
margin-left: auto;
}
.extra-area {
display: flex;
flex-direction: row;
align-items: center;
pointer-events: auto;
}
}
.nav-button {
padding: 0.5rem;
font-size: 2rem;
width: 2rem;
height: 2rem;
cursor: pointer;
display: flex;
justify-content: center;
align-items: flex-start;
flex-direction: column;
z-index: 2;
pointer-events: auto;
.bar {
width: 2rem;
height: 0.2rem;
background-color: var(--text-color);
transition: transform cubic-bezier(0.65, 0.05, 0.36, 1) 0.3s;
&:nth-child(1) {
transform: translate(0, -200%);
}
&:nth-child(3) {
transform: translate(0, 200%);
}
}
&.active {
.bar {
&:nth-child(1) {
transform: translate(0, 100%) rotateZ(45deg) scaleX(0.5)
translate(-50%);
}
&:nth-child(2) {
transform: rotateZ(-45deg);
}
&:nth-child(3) {
transform: translate(0, -100%) rotateZ(45deg) scaleX(0.5)
translate(50%);
}
}
}
}
.spacer {
flex-grow: 1;
}
.back-arrow {
padding-left: 1rem;
height: 2rem;
width: 2rem;
pointer-events: auto;
.arrow1 {
height: 1rem;
width: 1rem;
border-left: 0.15rem solid;
border-bottom: 0.15rem solid;
transform: translateY(0.38rem) rotate(45deg);
}
.arrow2 {
width: 1.2rem;
height: 0.15rem;
background-color: currentcolor;
transform: translate(0.5rem, -0.25rem);
}
}

View File

@@ -0,0 +1,11 @@
import { atom, useAtom } from 'jotai'
const extraAreaAtom = atom([])
export function useAppbar() {
const [extraArea, setExtraArea] = useAtom(extraAreaAtom)
return {
extraArea,
setExtraArea,
}
}

View File

@@ -0,0 +1,31 @@
import CONFIG from '!/config.json'
import { useCallback } from 'react'
import { atom, useAtom } from 'jotai'
const officialUpdateAtom = atom({})
let operators = []
CONFIG.operators.forEach((item) => {
operators = [...operators, ...item]
})
const OPERATORS = operators
export function useConfig() {
const config = CONFIG
const operators = OPERATORS
const [officialUpdate, setOfficialUpdate] = useAtom(officialUpdateAtom)
const fetchOfficialUpdate = useCallback(async () => {
const res = await fetch(
'https://raw.githubusercontent.com/Halyul/aklive2d/main/official_update.json'
)
const data = await res.json().catch((e) => {
console.error(e)
return {
length: 0,
}
})
setOfficialUpdate(data)
}, [setOfficialUpdate])
return { config, operators, officialUpdate, fetchOfficialUpdate }
}

View File

@@ -0,0 +1,43 @@
import { useEffect } from 'react'
import { atom, useAtom } from 'jotai'
import { useI18n } from '@/state/language'
const keyAtom = atom('')
const titleAtom = atom('')
const tabsAtom = atom([])
const currentTabAtom = atom(null)
const appbarExtraAreaAtom = atom([])
const headerIconAtom = atom(null)
const fastNaviationAtom = atom([])
export function useHeader() {
const [key, setTitle] = useAtom(keyAtom)
const [title, setRealTitle] = useAtom(titleAtom)
const [tabs, setTabs] = useAtom(tabsAtom)
const [currentTab, setCurrentTab] = useAtom(currentTabAtom)
const [appbarExtraArea, setAppbarExtraArea] = useAtom(appbarExtraAreaAtom)
const [headerIcon, setHeaderIcon] = useAtom(headerIconAtom)
const [fastNavigation, setFastNavigation] = useAtom(fastNaviationAtom)
const { i18n } = useI18n()
useEffect(() => {
const newTitle = i18n(key)
document.title = `${newTitle} - ${import.meta.env.VITE_APP_TITLE}`
setRealTitle(newTitle)
}, [i18n, key, setRealTitle])
return {
title,
setTitle,
tabs,
setTabs,
currentTab,
setCurrentTab,
appbarExtraArea,
setAppbarExtraArea,
headerIcon,
setHeaderIcon,
fastNavigation,
setFastNavigation,
}
}

View File

@@ -0,0 +1,20 @@
import React from 'react'
import buildConfig from '!/config.json'
export default (path = null, skipPageView = false) => {
React.useEffect(() => {
if (!skipPageView && import.meta.env.MODE !== 'development') {
try {
window.counterscale = {
q: [
['set', 'siteId', buildConfig.insight_id],
['trackPageview', { path }],
],
}
window.counterscaleOnDemandTrack()
} catch (err) {
console.warn && console.warn(err.message)
}
}
}, [path, skipPageView])
}

View File

@@ -0,0 +1,38 @@
import { atom, useAtom, useAtomValue } from 'jotai'
import { atomWithStorage } from 'jotai/utils'
import i18nObject from '@/i18n'
const language = i18nObject.available.includes(navigator.language)
? navigator.language
: 'en-US'
const textDefaultLang = 'en-US'
const languageAtom = atomWithStorage('language', language)
const alternateLangAtom = atom((get) => {
const language = get(languageAtom)
return language.startsWith('en') ? 'zh-CN' : language
})
export function useI18n() {
const language = useAtomValue(languageAtom)
return {
i18n: (key, preferredLanguage = language) => {
if (i18nObject.key[key]) {
return i18nObject.key[key][preferredLanguage]
}
return key
},
i18nValues: i18nObject,
}
}
export function useLanguage() {
const [language, setLanguage] = useAtom(languageAtom)
const alternateLang = useAtomValue(alternateLangAtom)
return {
textDefaultLang,
language,
setLanguage,
alternateLang,
}
}

View File

@@ -0,0 +1,5 @@
import baseConfig from '@aklive2d/stylelint-config'
/** @type {import('stylelint').Config} */
export default {
...baseConfig,
}

View File

@@ -0,0 +1,45 @@
import path from 'node:path'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import config from '@aklive2d/config'
import * as showcaseDirs from '@aklive2d/showcase'
import { copyDirectoryData } from '@aklive2d/vite-helpers'
// https://vite.dev/config/
export default defineConfig(async () => {
const dataDir = path.resolve(import.meta.dirname, config.dir_name.data)
const publicDir = path.resolve(showcaseDirs.DIST_DIR)
await copyDirectoryData({ dataDir, publicDir })
return {
envDir: dataDir,
plugins: [react()],
publicDir,
resolve: {
alias: {
'@': path.resolve('./src'),
'!': dataDir,
},
},
build: {
emptyOutDir: false,
outDir: publicDir,
rollupOptions: {
output: {
entryFileNames: `${config.directory.assets_dir}/[name]-[hash:8].js`,
chunkFileNames: `${config.directory.assets_dir}/[name]-[hash:8].js`,
assetFileNames: `${config.directory.assets_dir}/[name]-[hash:8].[ext]`,
manualChunks: (id) => {
if (id.includes('node_modules')) {
return 'vendor' // all other package goes here
} else if (
id.includes('data') &&
id.includes('.json')
) {
return 'assets'
}
},
},
},
},
}
})