├── src ├── lib │ ├── views │ │ └── index.ts │ ├── platforms │ │ ├── index.ts │ │ ├── types.d.ts │ │ ├── YouTube.ts │ │ ├── bilibili.ts │ │ └── Niconico.ts │ ├── sourceType.ts │ ├── vocadb │ │ └── types.d.ts │ ├── material │ │ └── material.ts │ ├── api │ │ └── types.d.ts │ ├── utils.ts │ └── auth │ │ └── index.ts ├── app │ ├── icon.ico │ ├── api │ │ ├── v1 │ │ │ ├── types.d.ts │ │ │ └── route.ts │ │ ├── layout.tsx │ │ └── page.tsx │ ├── manifest.ts │ ├── [lang] │ │ ├── auth │ │ │ └── login │ │ │ │ ├── page.tsx │ │ │ │ └── login-form.tsx │ │ ├── about │ │ │ └── page.tsx │ │ ├── song │ │ │ ├── [id] │ │ │ │ ├── delete-song-button copy.tsx │ │ │ │ ├── refresh-song-button.tsx │ │ │ │ └── views-chart.tsx │ │ │ └── add │ │ │ │ ├── page.tsx │ │ │ │ └── add-song-form.tsx │ │ ├── settings │ │ │ ├── page.tsx │ │ │ ├── types.ts │ │ │ └── index.ts │ │ ├── layout.tsx │ │ ├── rankings │ │ │ ├── utils.ts │ │ │ ├── rankings-action-bar.tsx │ │ │ └── trending │ │ │ │ └── page.tsx │ │ ├── artist │ │ │ └── [id] │ │ │ │ ├── artist-songs.tsx │ │ │ │ ├── related-artists.tsx │ │ │ │ └── co-artists.tsx │ │ └── page.tsx │ └── actions │ │ ├── insertVocaDBSong.ts │ │ └── login.ts ├── components │ ├── filter │ │ ├── types.ts │ │ ├── minimal-filter.tsx │ │ ├── full-filter.tsx │ │ ├── filter.tsx │ │ ├── switch-filter.tsx │ │ ├── binary-toggle-filter.tsx │ │ ├── active-filter.tsx │ │ ├── date-filter.tsx │ │ ├── number-input-filter.tsx │ │ ├── input-filter.tsx │ │ ├── number-select-filter.tsx │ │ ├── toggle-group-filter.tsx │ │ └── select-filter.tsx │ ├── rankings │ │ ├── rankings-list.tsx │ │ ├── rankings-grid.tsx │ │ ├── rankings-list-skeleton-item.tsx │ │ ├── rankings-grid-skeleton-item.tsx │ │ ├── rankings-api-error.tsx │ │ ├── rankings-container.tsx │ │ ├── rankings-skeleton.tsx │ │ ├── rankings-item-trailing.tsx │ │ ├── rankings-grid-item.tsx │ │ ├── transitioning-rankings-grid-item.tsx │ │ ├── rankings-page-selector.tsx │ │ └── rankings-list-item.tsx │ ├── material │ │ ├── divider.tsx │ │ ├── vertical-divider.tsx │ │ ├── icon.tsx │ │ ├── minimal-icon-button.tsx │ │ ├── icon-button.tsx │ │ ├── filled-icon-button.tsx │ │ ├── floating-action-button.tsx │ │ ├── base-icon-button.tsx │ │ ├── filter-chip.tsx │ │ ├── filled-button.tsx │ │ └── switch.tsx │ ├── entity │ │ ├── artists-grid.tsx │ │ ├── artist-card-skeleton-item.tsx │ │ ├── artists-skeleton.tsx │ │ ├── entity-section.tsx │ │ └── artist-card.tsx │ ├── formatters │ │ ├── number-formatter.tsx │ │ ├── years-since-formatter.tsx │ │ ├── date-formatter.tsx │ │ ├── entity-name.tsx │ │ └── song-artists-label.tsx │ ├── providers │ │ ├── language-dictionary-provider.tsx │ │ ├── providers.tsx │ │ └── settings-provider.tsx │ ├── image.tsx │ ├── scripts │ │ └── gtag.tsx │ ├── index.ts │ ├── footer.tsx │ ├── entity-thumbnail.tsx │ ├── transitions │ │ ├── scrim.tsx │ │ ├── expander.tsx │ │ ├── fade-in-out.tsx │ │ ├── modal.tsx │ │ └── modal-drawer.tsx │ ├── search-bar.tsx │ ├── logo.tsx │ └── navbar.tsx ├── data │ ├── extensions │ │ ├── spellfix.so │ │ └── spellfix.dll │ ├── initializers │ │ ├── auth.ts │ │ └── songsData.ts │ └── index.ts ├── localization │ ├── docs │ │ ├── ja │ │ │ └── about.md │ │ ├── en │ │ │ └── about.md │ │ ├── es │ │ │ └── about.md │ │ └── fr │ │ │ └── about.md │ ├── index.ts │ └── DictionaryTokenMaps.ts └── middleware.ts ├── .eslintrc.json ├── public ├── yt_icon.png ├── bili_icon.png ├── nico_icon.png └── voca-db-icon.png ├── postcss.config.js ├── .env.local.template ├── .github └── workflows │ └── main.yml ├── .gitignore ├── tsconfig.json ├── next.config.js ├── README.md ├── package.json ├── tailwind.config.js └── CODE_OF_CONDUCT.md /src/lib/views/index.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /src/app/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duosii/vocaloid-rankings/HEAD/src/app/icon.ico -------------------------------------------------------------------------------- /public/yt_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duosii/vocaloid-rankings/HEAD/public/yt_icon.png -------------------------------------------------------------------------------- /src/components/filter/types.ts: -------------------------------------------------------------------------------- 1 | export enum ToggleStatus { 2 | INCLUDED, 3 | EXCLUDED 4 | } -------------------------------------------------------------------------------- /public/bili_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duosii/vocaloid-rankings/HEAD/public/bili_icon.png -------------------------------------------------------------------------------- /public/nico_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duosii/vocaloid-rankings/HEAD/public/nico_icon.png -------------------------------------------------------------------------------- /public/voca-db-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duosii/vocaloid-rankings/HEAD/public/voca-db-icon.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/data/extensions/spellfix.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duosii/vocaloid-rankings/HEAD/src/data/extensions/spellfix.so -------------------------------------------------------------------------------- /src/data/extensions/spellfix.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/duosii/vocaloid-rankings/HEAD/src/data/extensions/spellfix.dll -------------------------------------------------------------------------------- /src/app/api/v1/types.d.ts: -------------------------------------------------------------------------------- 1 | import { User } from "@/data/types" 2 | 3 | export interface GraphQLContext { 4 | user: User | null 5 | } -------------------------------------------------------------------------------- /src/lib/platforms/index.ts: -------------------------------------------------------------------------------- 1 | export const defaultFetchHeaders: {[key: string]: string} = { 2 | 'User-Agent': process.env?.USER_AGENT || '' 3 | } -------------------------------------------------------------------------------- /src/components/rankings/rankings-list.tsx: -------------------------------------------------------------------------------- 1 | export function RankingsList( 2 | { 3 | children 4 | }: { 5 | children: React.ReactNode 6 | } 7 | ) { 8 | return
    {children}
9 | } -------------------------------------------------------------------------------- /src/components/material/divider.tsx: -------------------------------------------------------------------------------- 1 | export function Divider( 2 | { 3 | className = '' 4 | }: { 5 | className?: string 6 | } 7 | ) { 8 | return ( 9 |
10 | ) 11 | } -------------------------------------------------------------------------------- /.env.local.template: -------------------------------------------------------------------------------- 1 | # Insert your youtube API key here 2 | YOUTUBE_API_KEY= 3 | 4 | # Salt for the bcrypt hashing algorithm. 5 | BCRYPT_SALT_ROUNDS=10 6 | 7 | # The tag for google analytics 8 | GOOGLE_ANALYTICS_TAG= 9 | 10 | # The user agent to use for API requests 11 | USER_AGENT=VOCALOID-RANKINGS -------------------------------------------------------------------------------- /src/components/material/vertical-divider.tsx: -------------------------------------------------------------------------------- 1 | export function VerticalDivider( 2 | { 3 | className = '' 4 | }: { 5 | className?: string 6 | } 7 | ) { 8 | return ( 9 |
10 | ) 11 | } -------------------------------------------------------------------------------- /src/components/entity/artists-grid.tsx: -------------------------------------------------------------------------------- 1 | export default function ArtistsGrid( 2 | { 3 | className = '', 4 | children 5 | }: { 6 | className?: string 7 | children?: React.ReactNode 8 | } 9 | ) { 10 | return
{children}
11 | } -------------------------------------------------------------------------------- /src/app/api/layout.tsx: -------------------------------------------------------------------------------- 1 | export const metadata = { 2 | title: 'Next.js', 3 | description: 'Generated by Next.js', 4 | } 5 | 6 | export default function RootLayout({ 7 | children, 8 | }: { 9 | children: React.ReactNode 10 | }) { 11 | return ( 12 | 13 | {children} 14 | 15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/platforms/types.d.ts: -------------------------------------------------------------------------------- 1 | export type VideoId = string 2 | 3 | export interface VideoThumbnails { 4 | default: string 5 | quality: string 6 | } 7 | 8 | export interface Platform { 9 | 10 | getViews(videoId: VideoId): Promise 11 | 12 | getThumbnails(videoId: VideoId): Promise 13 | 14 | } -------------------------------------------------------------------------------- /src/components/material/icon.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from "react" 2 | 3 | export function Icon( 4 | { 5 | icon, 6 | className = '', 7 | style 8 | }: { 9 | icon: string 10 | className?: string 11 | style?: CSSProperties 12 | } 13 | ) { 14 | return ( 15 | {icon} 16 | ) 17 | } -------------------------------------------------------------------------------- /src/components/rankings/rankings-grid.tsx: -------------------------------------------------------------------------------- 1 | export function RankingsGrid ( 2 | { 3 | columnsClassName = 'xl:grid-cols-8 lg:grid-cols-6 md:grid-cols-5 sm:grid-cols-3 grid-cols-2', 4 | children 5 | }: { 6 | columnsClassName?: string, 7 | children: React.ReactNode 8 | } 9 | ) { 10 | return
    {children}
11 | } -------------------------------------------------------------------------------- /src/components/filter/minimal-filter.tsx: -------------------------------------------------------------------------------- 1 | export function MinimalFilterElement( 2 | { 3 | name, 4 | children, 5 | className = '' 6 | }: { 7 | name: string, 8 | children?: React.ReactNode 9 | className?: string 10 | } 11 | ) { 12 | return ( 13 |
  • 14 | {children} 15 |
  • 16 | ) 17 | } -------------------------------------------------------------------------------- /src/components/rankings/rankings-list-skeleton-item.tsx: -------------------------------------------------------------------------------- 1 | export function RankingsSkeletonListItem( 2 | { 3 | keyValue 4 | }: { 5 | keyValue: number 6 | } 7 | ) { 8 | return ( 9 |
  • 10 | {/* spacer to get the height right */} 11 |
    12 |
  • 13 | ) 14 | } -------------------------------------------------------------------------------- /src/components/material/minimal-icon-button.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEventHandler } from "react" 2 | import { Icon } from "./icon" 3 | 4 | export function MinimalIconButton( 5 | { 6 | icon, 7 | onClick 8 | }: { 9 | icon: string 10 | onClick?: MouseEventHandler 11 | } 12 | ) { 13 | return ( 14 | 15 | ) 16 | } -------------------------------------------------------------------------------- /src/components/entity/artist-card-skeleton-item.tsx: -------------------------------------------------------------------------------- 1 | export function ArtistCardSkeletonItem( 2 | { 3 | className = '' 4 | }: { 5 | className?: string 6 | } 7 | ) { 8 | return ( 9 |
    10 |
    11 |
    12 |
    13 | ) 14 | } -------------------------------------------------------------------------------- /src/components/rankings/rankings-grid-skeleton-item.tsx: -------------------------------------------------------------------------------- 1 | export function RankingsSkeletonGridItem( 2 | { 3 | keyValue 4 | }: { 5 | keyValue: number 6 | } 7 | ) { 8 | return ( 9 |
  • 10 |
    11 |
    12 |
    13 |
  • 14 | ) 15 | } -------------------------------------------------------------------------------- /src/components/rankings/rankings-api-error.tsx: -------------------------------------------------------------------------------- 1 | import { APIError, GraphQLResponseError } from "graphql-hooks"; 2 | 3 | export function RankingsApiError( 4 | { 5 | error 6 | }: { 7 | error?: APIError 8 | } 9 | ) { 10 | const graphQLErrors = error?.graphQLErrors 11 | 12 | const errorMessage: string | undefined = error?.fetchError?.message || (graphQLErrors && graphQLErrors[0]?.message) || error?.httpError?.body 13 | 14 | return ( 15 |

    {errorMessage}

    16 | ) 17 | } -------------------------------------------------------------------------------- /src/components/formatters/number-formatter.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | export const numberFormatter = new Intl.NumberFormat() 3 | export const shortenedNumberFormatter = new Intl.NumberFormat(undefined, { 4 | notation: 'compact' 5 | }) 6 | 7 | export function NumberFormatter( 8 | { 9 | number, 10 | compact = false 11 | }: { 12 | number: number 13 | compact?: boolean 14 | } 15 | ) { 16 | return compact ? ( 17 | {shortenedNumberFormatter.format(number)} 18 | ) : ( 19 | numberFormatter.format(number) 20 | ) 21 | } -------------------------------------------------------------------------------- /src/components/formatters/years-since-formatter.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useLocale } from "../providers/language-dictionary-provider" 4 | 5 | export function YearsSinceFormatter( 6 | { 7 | date 8 | }: { 9 | date: Date 10 | } 11 | ) { 12 | const now = new Date() 13 | const langDict = useLocale() 14 | 15 | const yearsDiff = Math.ceil(now.getFullYear() - date.getFullYear()) 16 | 17 | return ( 18 | <> 19 | {yearsDiff > 1 ? langDict.home_years_since_release.replace(':years', yearsDiff.toString()) : langDict.home_year_since_release} 20 | 21 | ) 22 | } -------------------------------------------------------------------------------- /src/components/formatters/date-formatter.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | export const dateFormatter = new Intl.DateTimeFormat() 3 | export const shortenedDateFormatter = new Intl.DateTimeFormat(undefined, { 4 | month: 'numeric', 5 | day: '2-digit', 6 | }) 7 | 8 | export function DateFormatter( 9 | { 10 | date, 11 | compact = false 12 | }: { 13 | date: Date 14 | compact?: boolean 15 | } 16 | ) { 17 | const formatter = compact ? shortenedDateFormatter : dateFormatter 18 | return ( 19 | 22 | ) 23 | } -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Refresh All Song Views 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' # At midnight UTC 6 | 7 | jobs: 8 | refreshViews: 9 | runs-on: ubuntu-latest # The type of runner that the job will run on 10 | 11 | steps: 12 | - name: Checkout repo 13 | uses: actions/checkout@v3 # Checks out your repository under $GITHUB_WORKSPACE 14 | 15 | - name: Make GraphQL Mutation 16 | run: | 17 | curl -X POST \ 18 | -H "Content-Type: application/json" \ 19 | --data '{ "query": "mutation { refreshAllSongsViews }" }' \ 20 | https://vocaloid-rankings.fly.dev/api/v1 21 | -------------------------------------------------------------------------------- /src/components/rankings/rankings-container.tsx: -------------------------------------------------------------------------------- 1 | import { RankingsViewMode } from "@/app/[lang]/rankings/types" 2 | import { RankingsList } from "./rankings-list" 3 | import { RankingsGrid } from "./rankings-grid" 4 | 5 | export function RankingsContainer( 6 | { 7 | viewMode, 8 | columnsClassName, 9 | children 10 | }: { 11 | viewMode: RankingsViewMode 12 | columnsClassName?: string 13 | children: React.ReactNode 14 | } 15 | ) { 16 | return viewMode == RankingsViewMode.LIST ? {children} : {children} 17 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | 37 | #database 38 | /src/data/database 39 | /src/data/database/ 40 | /src/data/toClone 41 | .dockerignore 42 | Dockerfile 43 | fly.toml 44 | /.get/ -------------------------------------------------------------------------------- /src/components/entity/artists-skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { RankingsViewMode } from "@/app/[lang]/rankings/types"; 2 | import { ArtistCardSkeletonItem } from "./artist-card-skeleton-item"; 3 | import ArtistsGrid from "./artists-grid"; 4 | 5 | export function ArtistsSkeleton( 6 | { 7 | elementCount, 8 | className 9 | }: { 10 | elementCount: number 11 | className?: string 12 | } 13 | ) { 14 | const skeletonItems: JSX.Element[] = [] 15 | 16 | for (let i = 0; i < elementCount; i++) { 17 | skeletonItems.push() 18 | } 19 | 20 | return {skeletonItems} 21 | } -------------------------------------------------------------------------------- /src/components/material/icon-button.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties, MouseEventHandler } from "react" 2 | import { BaseIconButton } from "./base-icon-button" 3 | 4 | export function IconButton( 5 | { 6 | icon, 7 | href, 8 | style, 9 | onClick, 10 | className = '' 11 | }: { 12 | icon: string 13 | href?: string 14 | style?: CSSProperties 15 | onClick?: MouseEventHandler 16 | className?: string 17 | } 18 | ) { 19 | return ( 20 | 21 | ) 22 | } -------------------------------------------------------------------------------- /src/components/entity/entity-section.tsx: -------------------------------------------------------------------------------- 1 | export function EntitySection( 2 | { 3 | children, 4 | title, 5 | titleSupporting 6 | }: { 7 | children: React.ReactNode, 8 | title: string, 9 | className?: string, 10 | titleSupporting?: React.ReactNode 11 | } 12 | ) { 13 | return
    14 |
    15 |

    {title}

    16 |
    {titleSupporting}
    17 |
    18 | {children} 19 |
    20 | } -------------------------------------------------------------------------------- /src/app/manifest.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from 'next' 2 | 3 | export default function manifest(): MetadataRoute.Manifest { 4 | return { 5 | name: 'Vocaloid Rankings', 6 | short_name: 'VocaRankings', 7 | description: 'Explore vocal synthesizer songs that are ranked based on their total view counts with powerful filtering capabilities.', 8 | start_url: '/', 9 | display: 'standalone', 10 | background_color: 'var(--md-sys-color-background)', 11 | theme_color: 'var(--md-sys-color-primary)', 12 | icons: [ 13 | { 14 | src: '/github-mark-white.png', 15 | sizes: 'any', 16 | type: 'image/png', 17 | purpose: 'any' 18 | }, 19 | ], 20 | } 21 | } -------------------------------------------------------------------------------- /src/components/providers/language-dictionary-provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { LanguageDictionary } from "@/localization"; 3 | import { createContext, useContext } from "react"; 4 | 5 | const localeContext = createContext({} as LanguageDictionary) 6 | 7 | export const useLocale = () => useContext(localeContext) 8 | 9 | export function LanguageDictionaryProvider( 10 | { 11 | dictionary, 12 | children 13 | }: { 14 | dictionary: LanguageDictionary 15 | children: React.ReactNode 16 | } 17 | ) { 18 | return ( 19 | 22 | {children} 23 | 24 | ) 25 | } -------------------------------------------------------------------------------- /src/components/filter/full-filter.tsx: -------------------------------------------------------------------------------- 1 | export function FullFilterElement( 2 | { 3 | name, 4 | nameTrailing, 5 | children, 6 | className = '' 7 | }: { 8 | name: string, 9 | nameTrailing?: React.ReactNode 10 | children?: React.ReactNode 11 | shrink?: boolean 12 | className?: string 13 | } 14 | ) { 15 | return ( 16 |
  • 17 |
    18 |

    {name}

    19 | {nameTrailing} 20 |
    21 | {children} 22 |
  • 23 | ) 24 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /src/components/filter/filter.tsx: -------------------------------------------------------------------------------- 1 | import { FullFilterElement } from "./full-filter" 2 | import { MinimalFilterElement } from "./minimal-filter" 3 | 4 | export function FilterElement( 5 | { 6 | name, 7 | nameTrailing, 8 | minimal = false, 9 | children, 10 | className = '' 11 | }: { 12 | name: string, 13 | nameTrailing?: React.ReactNode 14 | minimal?: boolean 15 | children?: React.ReactNode 16 | shrink?: boolean 17 | className?: string 18 | } 19 | ) { 20 | return minimal ? {children} 21 | : {children} 22 | } -------------------------------------------------------------------------------- /src/components/formatters/entity-name.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { useSettings } from "@/components/providers/settings-provider" 3 | import { NameType, Names } from "@/data/types" 4 | import { getEntityName } from "@/localization" 5 | import { useEffect, useState } from "react" 6 | 7 | export function EntityName( 8 | { 9 | names, 10 | preferred 11 | }: { 12 | names: Names, 13 | preferred: NameType 14 | } 15 | ) { 16 | const [preferredNameType, setPreferredNameType] = useState(preferred) 17 | const { settings } = useSettings() 18 | 19 | useEffect(() => { 20 | setPreferredNameType(settings.titleLanguage) 21 | }, [settings.titleLanguage]) 22 | 23 | return ( 24 | getEntityName(names, preferredNameType) 25 | ) 26 | } -------------------------------------------------------------------------------- /src/components/material/filled-icon-button.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties, MouseEventHandler } from "react" 2 | import { BaseIconButton } from "./base-icon-button" 3 | 4 | export function FilledIconButton( 5 | { 6 | icon, 7 | href, 8 | style, 9 | className = '', 10 | onClick 11 | }: { 12 | icon: string 13 | href?: string 14 | style?: CSSProperties 15 | className?: string 16 | onClick?: MouseEventHandler 17 | } 18 | ) { 19 | return ( 20 | 21 | ) 22 | } -------------------------------------------------------------------------------- /src/components/image.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from "react" 2 | 3 | export default function Image( 4 | { 5 | 6 | src, 7 | alt, 8 | height, 9 | width, 10 | fill, 11 | style, 12 | className, 13 | priority 14 | }: { 15 | src: string 16 | alt: string 17 | height?: number 18 | width?: number 19 | fill?: boolean 20 | style?: CSSProperties 21 | className?: string 22 | priority?: boolean 23 | } 24 | ) { 25 | return {alt} 37 | } -------------------------------------------------------------------------------- /src/components/material/floating-action-button.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEventHandler } from "react"; 2 | import { Icon } from "./icon"; 3 | 4 | export function FloatingActionButton( 5 | { 6 | icon, 7 | className = '', 8 | onClick 9 | }: { 10 | icon: string, 11 | className?: string 12 | onClick?: MouseEventHandler 13 | } 14 | ) { 15 | return ( 16 | 22 | ) 23 | } -------------------------------------------------------------------------------- /src/components/filter/switch-filter.tsx: -------------------------------------------------------------------------------- 1 | import { Switch } from "../material/switch" 2 | 3 | export function SwitchFilterElement( 4 | { 5 | name, 6 | value, 7 | onValueChanged 8 | }: { 9 | name: string 10 | value: boolean 11 | onValueChanged?: (newValue: boolean) => void 12 | } 13 | ) { 14 | function setValue(newValue: boolean) { 15 | if (value != newValue && onValueChanged) onValueChanged(newValue) 16 | } 17 | 18 | return ( 19 |
    setValue(!value)}> 20 |

    {name}

    21 | 22 |
    23 | ) 24 | } -------------------------------------------------------------------------------- /src/components/scripts/gtag.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import Script from "next/script"; 3 | import { useSettings } from "../providers/settings-provider"; 4 | import { useEffect, useState } from "react"; 5 | 6 | export function GoogleAnalytics( 7 | { 8 | tag 9 | }: { 10 | tag: string 11 | } 12 | ) { 13 | const { settings } = useSettings() 14 | 15 | return settings.googleAnalytics ? ( 16 | <> 17 | 27 | 28 | ) : undefined 29 | } -------------------------------------------------------------------------------- /src/data/initializers/auth.ts: -------------------------------------------------------------------------------- 1 | import { Database } from "better-sqlite3"; 2 | 3 | export default function init( 4 | database: Database, 5 | exists: Boolean 6 | ) { 7 | if (exists) { return } 8 | 9 | // create user table 10 | database.prepare(`CREATE TABLE IF NOT EXISTS users ( 11 | id TEXT PRIMARY KEY NOT NULL, 12 | username TEXT NOT NULL UNIQUE, 13 | password TEXT NOT NULL, 14 | created TEXT NOT NULL, 15 | last_login TEXT NOT NULL, 16 | access_level INTEGER NOT NULL 17 | )`).run() 18 | 19 | // create session table 20 | database.prepare(`CREATE TABLE IF NOT EXISTS sessions ( 21 | token TEXT PRIMARY KEY NOT NULL, 22 | expires TEXT NOT NULL, 23 | user_id TEXT NOT NULL, 24 | stay_logged_in INTEGER NOT NULL DEFAULT 0, 25 | FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE 26 | )`).run() 27 | } -------------------------------------------------------------------------------- /src/components/providers/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { SettingsProvider } from '@/components/providers/settings-provider' 3 | import { ThemeProvider } from 'next-themes' 4 | import { graphClient } from '@/lib/api'; 5 | import { ClientContext } from 'graphql-hooks' 6 | import { LanguageDictionaryProvider } from './language-dictionary-provider'; 7 | import { LanguageDictionary } from '@/localization'; 8 | 9 | export function Providers( 10 | { 11 | dictionary, 12 | children 13 | }: { 14 | dictionary: LanguageDictionary 15 | children: React.ReactNode 16 | } 17 | ) { 18 | return ( 19 | 20 | 21 | 22 | 23 | {children} 24 | 25 | 26 | 27 | 28 | ) 29 | } -------------------------------------------------------------------------------- /src/app/api/v1/route.ts: -------------------------------------------------------------------------------- 1 | 2 | import { graphql } from "graphql" 3 | import { Schema } from "./schema" 4 | import { NextResponse } from "next/server" 5 | import { GraphQLContext } from "./types" 6 | import { getSession, getUser } from "@/data/auth" 7 | 8 | export async function POST( 9 | request: Request 10 | ) { 11 | const { query, variables }: {query: string, variables: {[key: string]: unknown}} = await request.json() 12 | 13 | const authorization = request.headers.get('authorization') 14 | const token = authorization ? authorization.split('Bearer')[1] : null 15 | const session = token ? await getSession(token.trim()) : null 16 | 17 | const response = await graphql({ 18 | schema: Schema, 19 | source: query, 20 | variableValues: variables, 21 | contextValue: { 22 | user: session ? await getUser(session.userId) : null 23 | } as GraphQLContext 24 | }) 25 | 26 | return NextResponse.json(response) 27 | } -------------------------------------------------------------------------------- /src/lib/sourceType.ts: -------------------------------------------------------------------------------- 1 | import { SourceType } from "@/data/types" 2 | 3 | // source type display data 4 | export interface SourceTypeDisplayData { 5 | color: string, 6 | textColor: string, 7 | videoURL: string, 8 | icon: string 9 | } 10 | export const SourceTypesDisplayData: { [key in SourceType]: SourceTypeDisplayData } = { 11 | [SourceType.YOUTUBE]: { 12 | color: '#ff0000', 13 | textColor: '#ffffff', 14 | videoURL: 'https://www.youtube.com/watch?v=', 15 | icon: '/yt_icon.png' 16 | }, 17 | [SourceType.NICONICO]: { 18 | color: 'var(--md-sys-color-on-surface)', 19 | textColor: 'var(--md-sys-color-surface)', 20 | videoURL: 'https://www.nicovideo.jp/watch/', 21 | icon: '/nico_icon.png' 22 | }, 23 | [SourceType.BILIBILI]: { 24 | color: '#079fd2', 25 | textColor: '#ffffff', 26 | videoURL: 'https://www.bilibili.com/video/', 27 | icon: '/bili_icon.png' 28 | }, 29 | } -------------------------------------------------------------------------------- /src/components/material/base-icon-button.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { Icon } from "./icon" 3 | import { CSSProperties, MouseEventHandler } from "react" 4 | 5 | export function BaseIconButton( 6 | { 7 | icon, 8 | className = '', 9 | href = '', 10 | style, 11 | onClick 12 | }: { 13 | 14 | icon: string 15 | className?: string 16 | href?: string 17 | style?: CSSProperties 18 | onClick?: MouseEventHandler 19 | } 20 | ) { 21 | const finalClassName = `w-[40px] h-[40px] rounded-full flex items-center justify-center ${className}` 22 | return ( 23 | href ? ( 24 | 25 | 26 | 27 | ) : ( 28 | 31 | ) 32 | ) 33 | } -------------------------------------------------------------------------------- /src/localization/docs/ja/about.md: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | ## 閲覧数のデータ 4 | このウェブサイトで追跡されている**非休止**状態のすべての曲の閲覧数は、毎日更新されます。 5 | 6 | **Vocaloidランキング**は現在、三つのプラットフォームから曲の閲覧数を集計しています。そのプラットフォームとは[YouTube](https://www.youtube.com)、[ニコニコ動画](https://www.nicovideo.jp/)、そして[bilibili](https://www.bilibili.tv)です。全ての曲の閲覧数はこれらのプラットフォームが提供する公式APIを通じて取得されています。 7 | 8 | --- 9 | 10 | ## 休止曲 11 | 休止されている曲は毎日閲覧数が更新されません。 12 | 代わりに、そのページが訪問されるときのみ、1日に一度まで、閲覧数が更新されます。 13 | 14 | 曲が休止状態になるには、以下の**すべて**の基準を満たす必要があります: 15 | 16 | 1. 曲が1日に1,500回未満の閲覧数であること。 17 | 2. 公開から一年以上が経過していること。 18 | 3. ウェブサイトに追加されてから三日以上が経過していること。 19 | 20 | --- 21 | 22 | ## 曲データ 23 | **Vocaloidランキング**は、閲覧数以外のすべての曲データを[VocaDB](https://vocadb.net/)から取得しています。 24 | これには曲名、ビデオリンク、歌手、プロデューサーなどのデータが含まれます。 25 | 26 | 結果として、このウェブサイト上のすべての曲IDは、VocaDBで使用されている曲IDと同一です。 27 | 28 | --- 29 | 30 | ## 曲の追加 31 | 曲は、[曲の追加](./song/add)ページを訪れて、そこに提示されている指示に従うことでウェブサイトに追加することができます。 32 | 33 | **Vocaloidランキング**に曲を追加するためには、以下の**すべて**の基準を満たす必要があります: 34 | 35 | 1. その曲がVocaDBにも掲載されていること。 36 | 2. 少なくとも一つのボーカルシンセサイザーが歌手として参加していること。 -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from "react" 2 | 3 | export enum Elevation { 4 | LOWEST, 5 | LOW, 6 | NORMAL, 7 | HIGH, 8 | HIGHEST 9 | } 10 | 11 | export enum ImageDisplayMode { 12 | SONG, 13 | VOCALIST, 14 | PRODUCER 15 | } 16 | 17 | export const elevationToClass: {[key in Elevation]: string} = { 18 | [Elevation.LOWEST]: 'surface-container-lowest', 19 | [Elevation.LOW]: 'surface-container-low', 20 | [Elevation.NORMAL]: 'surface-container', 21 | [Elevation.HIGH]: 'surface-container-high', 22 | [Elevation.HIGHEST]: 'surface-container-highest' 23 | } 24 | 25 | export const imageDisplayModeStyles: { [key in ImageDisplayMode]: CSSProperties } = { 26 | [ImageDisplayMode.SONG]: { 27 | transform: 'scaleX(1.45) scaleY(1.45)', 28 | objectPosition: 'center center' 29 | }, 30 | [ImageDisplayMode.VOCALIST]: { 31 | objectPosition: 'center top' 32 | }, 33 | [ImageDisplayMode.PRODUCER] : { 34 | objectPosition: 'center center' 35 | } 36 | } -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | protocol: 'https', 7 | hostname: 'img.youtube.com', 8 | pathname: '/vi/**' 9 | }, 10 | { 11 | protocol: 'https', 12 | hostname: 'static.vocadb.net' 13 | }, 14 | { 15 | protocol: 'https', 16 | hostname: 'nicovideo.cdn.nimg.jp', 17 | pathname: '/thumbnails/**' 18 | }, 19 | { 20 | protocol: 'http', 21 | hostname: 'i*.hdslb.com' 22 | }, 23 | ], 24 | }, 25 | webpack: (config) => { 26 | config.externals.push({ 27 | sharp: "commonjs sharp" 28 | }); 29 | config.module.rules.push( 30 | { 31 | test: /\.md$/, 32 | type: 'asset/source', 33 | } 34 | ) 35 | 36 | return config; 37 | }, 38 | transpilePackages: ['@mui/x-charts'] 39 | } 40 | 41 | module.exports = nextConfig 42 | -------------------------------------------------------------------------------- /src/components/rankings/rankings-skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { RankingsViewMode } from "@/app/[lang]/rankings/types"; 2 | import { RankingsSkeletonListItem } from "./rankings-list-skeleton-item"; 3 | import { RankingsSkeletonGridItem } from "./rankings-grid-skeleton-item"; 4 | import { RankingsList } from "./rankings-list"; 5 | import { RankingsGrid } from "./rankings-grid"; 6 | 7 | export function RankingsSkeleton( 8 | { 9 | elementCount, 10 | viewMode, 11 | columnsClassName 12 | }: { 13 | elementCount: number 14 | viewMode: RankingsViewMode 15 | columnsClassName?: string 16 | } 17 | ) { 18 | const skeletonItems: JSX.Element[] = [] 19 | const isListMode = viewMode == RankingsViewMode.LIST 20 | 21 | for (let i = 0; i < elementCount; i++) { 22 | skeletonItems.push(isListMode ? : ) 23 | } 24 | 25 | return isListMode ? {skeletonItems} : {skeletonItems} 26 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![icon](src/app/icon.ico) Vocaloid Rankings 2 | A website that uses data from YouTube, Niconico, bilibili, and [VocaDB](https://github.com/VocaDB/vocadb) to rank the most viewed vocal synthesizer songs. 3 | 4 | View a live version of the website at [https://vocaloid-rankings.fly.dev](https://vocaloid-rankings.fly.dev). 5 | 6 | ## Requirements 7 | * [Node.js v20.0.0 or higher](https://nodejs.org/en/download/current) 8 | 9 | ## Installation 10 | 1. Clone the repository 11 | ```bash 12 | git clone https://github.com/Duosion/vocaloid-rankings.git 13 | ``` 14 | 15 | 2. Rename `.env.local.template` to `.env.local` and fill in the following fields: 16 | * **YOUTUBE_API_KEY** 17 | - A google cloud API key with access to the [YouTube data API](https://developers.google.com/youtube/v3/getting-started). 18 | 19 | 3. Install dependencies 20 | ```bash 21 | npm install 22 | ``` 23 | 24 | 4. Run the server 25 | ```bash 26 | npm run dev 27 | ``` 28 | 29 | ## FAQ/Community 30 | Join the Discord server [here](https://discord.gg/By7z2kKVjx). 31 | 32 | ## Special Thanks 33 | Special thanks to [VocaDB](https://github.com/VocaDB/vocadb) for providing all non-view song data. -------------------------------------------------------------------------------- /src/app/[lang]/auth/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { Locale, getDictionary } from "@/localization" 2 | import { Divider } from "@/components/material/divider" 3 | import { Metadata } from "next" 4 | import { LoginForm } from "./login-form" 5 | 6 | export async function generateMetadata( 7 | { 8 | params 9 | }: { 10 | params: { 11 | lang: Locale 12 | } 13 | } 14 | ): Promise { 15 | const langDict = await getDictionary(params.lang) 16 | 17 | return { 18 | title: langDict.add_song, 19 | } 20 | } 21 | 22 | export default async function LoginPage( 23 | { 24 | params 25 | }: { 26 | params: { 27 | lang: Locale 28 | } 29 | } 30 | ) { 31 | 32 | // import language dictionary 33 | const lang = params.lang 34 | const langDict = await getDictionary(lang) 35 | return ( 36 |
    37 |

    {langDict.login}

    38 | 39 | 40 |
    41 | ) 42 | 43 | } -------------------------------------------------------------------------------- /src/app/actions/insertVocaDBSong.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { insertSong, songExists } from "@/data/songsData" 4 | import { getVocaDBSong, parseVocaDBSongId } from "@/lib/vocadb" 5 | import { LanguageDictionaryKey } from "@/localization" 6 | 7 | export interface InsertVocaDBSongActionResponse { 8 | error?: LanguageDictionaryKey | string, 9 | songId?: string 10 | } 11 | 12 | export async function insertVocaDBSong( 13 | formData: FormData 14 | ): Promise { 15 | try { 16 | const songUrl = formData.get('songUrl') 17 | const songId = songUrl ? parseVocaDBSongId(songUrl as string) : null 18 | if (!songId) throw new Error('add_song_invalid_url') 19 | 20 | if (await songExists(songId)) throw new Error('add_song_already_exists') 21 | await insertSong(await getVocaDBSong(songId)) 22 | 23 | return { 24 | songId: songId.toString() 25 | } 26 | 27 | } catch (error: any) { 28 | if (error instanceof Error) { 29 | return { 30 | error: error.message as LanguageDictionaryKey 31 | } 32 | } else { 33 | return { 34 | error: String(error) 35 | } 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /src/components/footer.tsx: -------------------------------------------------------------------------------- 1 | import { Locale, getDictionary } from "@/localization"; 2 | import Link from "next/link"; 3 | import { Divider } from "./material/divider"; 4 | 5 | export default async function Footer( 6 | { 7 | lang 8 | }: { 9 | lang: Locale 10 | } 11 | ) { 12 | const langDict = await getDictionary(lang) 13 | return ( 14 |
    15 | 16 |
      17 | 18 | 19 | 20 |
    21 |
    22 | ) 23 | } 24 | 25 | function FooterLink( 26 | { 27 | text, 28 | href 29 | }: 30 | { 31 | text: string, 32 | href: string 33 | } 34 | ) { 35 | return ( 36 |
  • {text}
  • 37 | ) 38 | } -------------------------------------------------------------------------------- /src/app/[lang]/about/page.tsx: -------------------------------------------------------------------------------- 1 | import { Locale, getDictionary } from "@/localization" 2 | import { Metadata } from "next" 3 | import Markdown from "react-markdown" 4 | 5 | export async function generateMetadata( 6 | { 7 | params 8 | }: { 9 | params: { 10 | lang: Locale 11 | } 12 | } 13 | ): Promise { 14 | const langDict = await getDictionary(params.lang) 15 | 16 | return { 17 | title: langDict.home_about, 18 | } 19 | } 20 | 21 | export default async function AddSongPage( 22 | { 23 | params 24 | }: { 25 | params: { 26 | lang: Locale 27 | } 28 | } 29 | ) { 30 | // import language dictionary 31 | const lang = params.lang 32 | const langDict = await getDictionary(lang) 33 | 34 | const markdown = await import(`@/localization/docs/${lang}/about.md`).then(module => module.default) 35 | 36 | return ( 37 |
    38 |

    {langDict.home_about}

    39 | {markdown} 40 |
    41 | ) 42 | 43 | } -------------------------------------------------------------------------------- /src/components/entity-thumbnail.tsx: -------------------------------------------------------------------------------- 1 | import Image from '@/components/image'; 2 | import { ImageDisplayMode, imageDisplayModeStyles } from '.'; 3 | 4 | export default function EntityThumbnail( 5 | { 6 | src, 7 | alt, 8 | imageDisplayMode = ImageDisplayMode.SONG, 9 | fill, 10 | width, 11 | height, 12 | fillColor 13 | }: { 14 | src: string 15 | alt: string 16 | imageDisplayMode?: ImageDisplayMode 17 | fill?: boolean 18 | width?: number 19 | height?: number 20 | fillColor?: string 21 | } 22 | ) { 23 | return ( 24 |
    25 | {alt} 36 |
    37 | ) 38 | } -------------------------------------------------------------------------------- /src/components/transitions/scrim.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from "react"; 2 | import { TransitionStatus } from "react-transition-group"; 3 | 4 | const transitionStyles: { [key in TransitionStatus]: CSSProperties } = { 5 | entering: { 6 | backgroundColor: 'rgba(0,0,0,0.8)', 7 | backdropFilter: 'blur(2px) saturate(2)' 8 | }, 9 | entered: { 10 | backgroundColor: 'rgba(0,0,0,0.8)', 11 | backdropFilter: 'blur(2px) saturate(2)' 12 | }, 13 | exiting: { 14 | backgroundColor: 'rgba(0,0,0,0)', 15 | backdropFilter: 'none' 16 | }, 17 | exited: { 18 | backgroundColor: 'rgba(0,0,0,0)', 19 | backdropFilter: 'none' 20 | }, 21 | unmounted: { 22 | backgroundColor: 'rgba(0,0,0,0)', 23 | backdropFilter: 'none' 24 | } 25 | } 26 | 27 | export function Scrim( 28 | { 29 | duration, 30 | state 31 | }: { 32 | duration: number, 33 | state: TransitionStatus 34 | } 35 | ) { 36 | return ( 37 |
    44 | ) 45 | } -------------------------------------------------------------------------------- /src/components/search-bar.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { FormEventHandler, SyntheticEvent } from "react" 4 | import { useRouter } from "next/navigation" 5 | import { Icon } from "./material/icon" 6 | 7 | export const SearchBar = ( 8 | { 9 | placeholder = "Search", 10 | className = '' 11 | }: { 12 | placeholder?: string 13 | className?: string 14 | } 15 | ) => { 16 | const router = useRouter() 17 | 18 | const handleSubmit: FormEventHandler = (event: SyntheticEvent) => { 19 | event.preventDefault() 20 | const target = event.target as typeof event.target & { 21 | query: { value: string }; 22 | }; 23 | router.push(`/search?query=${target.query.value}`) 24 | target.query.value = '' 25 | } 26 | 27 | return ( 28 |
    29 | 30 | 38 | 39 | ) 40 | } -------------------------------------------------------------------------------- /src/components/filter/binary-toggle-filter.tsx: -------------------------------------------------------------------------------- 1 | import { Switch } from "../material/switch" 2 | import { FilterElement } from "./filter" 3 | 4 | export function BinaryToggleFilterElement( 5 | { 6 | name, 7 | options, 8 | value, 9 | defaultValue, 10 | onValueChanged 11 | }: { 12 | name: string 13 | options: string[] 14 | value: number 15 | defaultValue: number 16 | onValueChanged?: (newValue: number) => void 17 | } 18 | ) { 19 | value = isNaN(value) ? defaultValue : value 20 | function setValue(newValue: number) { 21 | if (value != newValue && onValueChanged) onValueChanged(newValue) 22 | } 23 | 24 | return ( 25 |
    setValue(value == 0 ? 1 : 0)}> 26 |

    {name}

    27 |
    28 |

    {options[0]}

    29 | 30 |

    {options[1]}

    31 |
    32 |
    33 | ) 34 | } -------------------------------------------------------------------------------- /src/components/material/filter-chip.tsx: -------------------------------------------------------------------------------- 1 | import { MouseEventHandler } from "react" 2 | import { Icon } from "./icon" 3 | 4 | export enum FilterChipState { 5 | UNSELECTED, 6 | SELECTED, 7 | NEGATE 8 | } 9 | 10 | export function FilterChip( 11 | { 12 | label, 13 | state, 14 | onClick 15 | }: { 16 | label: string, 17 | state: FilterChipState, 18 | onClick?: MouseEventHandler 19 | } 20 | ) { 21 | const isUnselected = state == FilterChipState.UNSELECTED 22 | const isSelected = state == FilterChipState.SELECTED 23 | 24 | return ( 25 | 38 | ) 39 | } -------------------------------------------------------------------------------- /src/app/[lang]/song/[id]/delete-song-button copy.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { FilledButton } from "@/components/material/filled-button" 3 | import { APIError, GraphQLResponseError, useManualQuery } from "graphql-hooks" 4 | import { useRouter } from "next/navigation" 5 | 6 | const DELETE_SONG_QUERY = ` 7 | mutation deleteSong( 8 | $id: Int! 9 | ) { 10 | deleteSong(id: $id) 11 | } 12 | ` 13 | 14 | export function DeleteSongButton( 15 | { 16 | text, 17 | songId 18 | }: { 19 | text: string, 20 | songId: number 21 | } 22 | ) { 23 | const router = useRouter() 24 | 25 | const [refreshSong, { loading, error }] = useManualQuery(DELETE_SONG_QUERY, { 26 | variables: { id: songId }, 27 | }) 28 | 29 | const handleRefresh = () => refreshSong() 30 | .then(_ => { 31 | router.back() 32 | }) 33 | .catch(error => { }) 34 | 35 | 36 | const graphQLErrors = (error as APIError)?.graphQLErrors 37 | return ( 38 | <> 39 | 45 | {error ?

    { error?.fetchError?.message || (graphQLErrors && graphQLErrors[0]?.message) || error?.httpError?.body}

    : undefined} 46 | 47 | ) 48 | } -------------------------------------------------------------------------------- /src/app/actions/login.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import { getUserFromUsername } from "@/data/auth" 4 | import { login } from "@/lib/auth" 5 | import { LanguageDictionaryKey } from "@/localization" 6 | import { cookies } from "next/dist/client/components/headers" 7 | 8 | export interface LoginActionResponse { 9 | error?: LanguageDictionaryKey | string, 10 | success: boolean, 11 | session?: string 12 | } 13 | 14 | export async function loginAction( 15 | formData: FormData 16 | ): Promise { 17 | try { 18 | const username = formData.get('username') 19 | const password = formData.get('password') 20 | const user = username ? await getUserFromUsername(username as string) : null 21 | if (!user || !password) throw new Error('login_invalid_credentials') 22 | 23 | const session = await login(cookies(), user, password as string, formData.get('stayLoggedIn') as boolean | null || false) 24 | 25 | return { 26 | success: true, 27 | session: session.token 28 | } 29 | 30 | } catch (error: any) { 31 | if (error instanceof Error) { 32 | return { 33 | success: false, 34 | error: error.message as LanguageDictionaryKey 35 | } 36 | } else { 37 | return { 38 | success: false, 39 | error: String(error) 40 | } 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /src/app/[lang]/song/[id]/refresh-song-button.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { FilledButton } from "@/components/material/filled-button" 3 | import { APIError, GraphQLResponseError, useManualQuery } from "graphql-hooks" 4 | import { useRouter } from "next/navigation" 5 | 6 | const REFRESH_SONG_QUERY = ` 7 | mutation refreshSong( 8 | $id: Int! 9 | ) { 10 | refreshSongFromVocaDB(id: $id) { id } 11 | } 12 | ` 13 | 14 | export function RefreshSongButton( 15 | { 16 | text, 17 | songId 18 | }: { 19 | text: string, 20 | songId: number 21 | } 22 | ) { 23 | const router = useRouter() 24 | 25 | 26 | 27 | const [refreshSong, { loading, error }] = useManualQuery(REFRESH_SONG_QUERY, { 28 | variables: { id: songId } 29 | }) 30 | 31 | const handleRefresh = () => refreshSong() 32 | .then(_ => { 33 | router.refresh() 34 | }) 35 | .catch(error => { }) 36 | 37 | 38 | const graphQLErrors = (error as APIError)?.graphQLErrors 39 | return ( 40 | <> 41 | 47 | {error ?

    { error?.fetchError?.message || (graphQLErrors && graphQLErrors[0]?.message) || error?.httpError?.body}

    : undefined} 48 | 49 | ) 50 | } -------------------------------------------------------------------------------- /src/app/api/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { FilledButton } from "@/components/material/filled-button" 4 | import { FormEvent, MutableRefObject, useRef, useState } from "react" 5 | 6 | export default function ApiPage() { 7 | /* generate */ 8 | 9 | 10 | 11 | const [response, setResponse] = useState('') 12 | 13 | const inputRef: MutableRefObject = useRef(null) 14 | 15 | const onSubmit = async (formEvent: FormEvent) => { 16 | formEvent.preventDefault() 17 | fetch('/api/v1',{ 18 | method: 'POST', 19 | headers: { 20 | 'Content-Type': 'application/json', 21 | 'Accept': 'application/json' 22 | }, 23 | body: JSON.stringify({ 24 | query: inputRef.current?.value, 25 | variables: {id: 520007} 26 | }) 27 | }).then(response => response.json()) 28 | .then(json => { setResponse(JSON.stringify(json, undefined, 5)) }) 29 | } 30 | 31 | return ( 32 |
      33 |
      34 |

      Input

      35 |