start new website + layouts lib

This commit is contained in:
Prospector
2026-08-28 12:42:40 -07:00
parent 0ab9100c46
commit 2f77572b22
53 changed files with 5850 additions and 653 deletions
@@ -184,7 +184,7 @@ img {
}
}
@import '@modrinth/assets/omorphia.scss';
@import '@modrinth/assets/omorphia.css';
input {
border-radius: var(--size-rounded-sm);
+2 -2
View File
@@ -25,8 +25,8 @@ export default defineConfig({
replacesTitle: true,
},
customCss: [
'@modrinth/assets/styles/variables.scss',
'@modrinth/assets/styles/inter.scss',
'@modrinth/assets/styles/variables.css',
'@modrinth/assets/styles/inter.css',
'./src/styles/modrinth.css',
],
plugins: [
+1 -1
View File
@@ -1,4 +1,4 @@
@import '@modrinth/assets/styles/reset.scss';
@import '@modrinth/assets/styles/reset.css';
html {
--dark-color-text: #b0bac5;
+24
View File
@@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
+7
View File
@@ -0,0 +1,7 @@
**/.nuxt
**/dist
**/.output
**/.data
**/.wrangler
src/locales/**
src/assets/**/*.svg
+75
View File
@@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
+9
View File
@@ -0,0 +1,9 @@
import config from '@modrinth/tooling-config/eslint/nuxt.mjs'
export default config.append([
{
rules: {
'import/no-unresolved': 'off',
'no-undef': 'off',
},
},
])
+28
View File
@@ -0,0 +1,28 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
srcDir: 'src/',
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
devServer: {
port: 42069,
},
app: {
head: {
title: 'Modrinth 2',
htmlAttrs: {
lang: 'en',
},
link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }],
},
},
future: {
compatibilityVersion: 5,
},
css: ['~/assets/styles/styles.css'],
postcss: {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
},
})
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@modrinth/website",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope apps/website"
},
"dependencies": {
"@modrinth/assets": "workspace:*",
"@modrinth/layouts": "workspace:*",
"@modrinth/ui": "workspace:*",
"intl-messageformat": "^10.7.7",
"lru-cache": "^11.2.4",
"nuxt": "^4.5.2",
"vue": "^3.5.41",
"vue-router": "^5.2.0"
},
"devDependencies": {
"@formatjs/cli": "^6.2.12",
"@modrinth/tooling-config": "workspace:*",
"autoprefixer": "^10.5.4",
"postcss": "^8.5.26",
"tailwindcss": "^3.4.19",
"vite-tsconfig-paths": "^6.1.1"
}
}
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { NuxtModrinthClient } from '@modrinth/api-client'
import { provideModrinthClient, providePageContext } from '@modrinth/ui'
provideModrinthClient(
new NuxtModrinthClient({
labrinthBaseUrl: 'https://api.modrinth.com',
}),
)
providePageContext({
hierarchicalSidebarAvailable: ref(false),
showAds: ref(false),
adConsentAvailable: ref(false),
floatingActionBarOffsets: {
left: ref(0),
right: ref(0),
},
})
</script>
<template>
<div class="page-layout">
<NuxtPage />
</div>
</template>
<style scoped>
.page-layout {
@apply max-w-[min(80rem,100%-1.5rem)] mx-auto h-[100dvh] pt-6;
&:has(#page-sidebar:not(:empty)) {
@apply grid grid-cols-[1fr_300px];
grid-template-areas: 'anything sidebar';
}
}
</style>
<style>
#page-sidebar {
@apply pl-4 border-l border-surface-5;
grid-area: sidebar;
}
</style>
+182
View File
@@ -0,0 +1,182 @@
@import '@modrinth/ui/src/styles/tailwind-utilities.css';
@import '@modrinth/assets/omorphia.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: var(--font-standard, sans-serif), sans-serif;
color-scheme: dark;
--view-width: calc(100% - 5rem);
--expanded-view-width: calc(100% - 13rem);
--medal-promotion-bg: #000;
--medal-promotion-bg-orange: rgba(208, 246, 255, 0.25);
--medal-promotion-text-orange: #42abff;
--medal-promotion-bg-gradient: linear-gradient(
90deg,
rgba(66, 170, 255, 0.15),
rgba(0, 0, 0, 0) 100%
);
}
html.light-mode {
color-scheme: light;
}
body {
position: fixed;
width: 100%;
height: 100%;
overflow: hidden;
}
* {
box-sizing: border-box;
}
.card-divider {
background-color: var(--color-button-bg);
border: none;
color: var(--color-button-bg);
height: 1px;
margin: var(--gap-sm) 0;
}
.no-wrap {
white-space: nowrap;
}
.no-select {
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
a {
color: inherit;
text-decoration: none;
-webkit-font-smoothing: antialiased;
will-change: filter;
}
.badge {
display: flex;
border-radius: var(--radius-md);
white-space: nowrap;
align-items: center;
background-color: var(--color-bg);
padding-block: var(--gap-sm);
padding-inline: var(--gap-lg);
width: min-content;
svg {
width: 1.1rem;
height: 1.1rem;
margin-right: 0.5rem;
}
&.featured {
background-color: var(--color-brand-highlight);
color: var(--color-contrast);
}
}
* {
scrollbar-width: auto;
scrollbar-color: var(--color-scrollbar) var(--color-bg);
}
/* Chrome, Edge, and Safari */
*::-webkit-scrollbar {
width: 16px;
border: 3px solid transparent;
opacity: 0.5;
transition: opacity 0.2s ease-in-out;
}
*::-webkit-scrollbar:hover {
opacity: 1;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: var(--color-scrollbar);
border-radius: var(--radius-lg);
border: 5px solid transparent;
background-clip: content-box;
}
.highlighted {
box-shadow: 0 0 1rem var(--color-brand) !important;
}
.gecko {
background-color: var(--color-raised-bg);
box-shadow: none !important;
}
img {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.card-shadow {
box-shadow: var(--shadow-card);
}
input {
border-radius: var(--size-rounded-sm);
box-sizing: border-box;
border: 2px solid transparent;
/* safari iOS rounds inputs by default. set the appearance to none to prevent this */
appearance: none !important;
}
pre {
font-weight: var(--font-weight-regular);
}
input,
textarea {
background: var(--color-button-bg);
color: var(--color-text);
padding: 0.5rem 1rem;
font-weight: var(--font-weight-medium);
border: none;
outline: 2px solid transparent;
box-shadow:
var(--shadow-inset-sm),
0 0 0 0 transparent;
transition: box-shadow 0.1s ease-in-out;
min-height: 36px;
&:focus,
&:focus-visible {
box-shadow:
inset 0 0 0 transparent,
0 0 0 0.25rem var(--color-brand-shadow);
color: var(--color-button-text-active);
}
&:disabled,
&[disabled='true'] {
opacity: 0.6;
pointer-events: none;
cursor: not-allowed;
}
&:focus::placeholder {
opacity: 0.8;
}
&::placeholder {
color: var(--color-button-text);
opacity: 0.6;
}
}
@@ -0,0 +1,2 @@
{
}
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts"></script>
<template>
all the fun stuff:
<ul>
<li>
<NuxtLink to="/user/Prospector">User page</NuxtLink>
</li>
</ul>
</template>
+8
View File
@@ -0,0 +1,8 @@
<script setup lang="ts">
import { UserPage } from '@modrinth/layouts'
const { id } = useRoute().params
</script>
<template>
<UserPage :user-id="id" />
</template>
+237
View File
@@ -0,0 +1,237 @@
import { layoutsLocaleModules } from '@modrinth/layouts'
import {
type CrowdinMessages,
I18N_INJECTION_KEY,
type I18nContext,
LOCALES,
transformCrowdinMessages,
uiLocaleModules,
useDebugLogger,
} from '@modrinth/ui'
import IntlMessageFormat from 'intl-messageformat'
import { LRUCache } from 'lru-cache'
const debug = useDebugLogger('i18n')
const DEFAULT_LOCALE = 'en-US'
const websiteLocaleModules = import.meta.glob<{ default: CrowdinMessages }>(
'../locales/*/index.json',
{ eager: false },
)
const messageCache = new LRUCache<string, Record<string, string>>({ max: 10 })
const formatterCache = new LRUCache<string, IntlMessageFormat>({ max: 1000 })
const loadingPromises = new Map<string, Promise<void>>()
type LocaleModules = Record<string, () => Promise<{ default: CrowdinMessages }>>
function findLocaleLoader(modules: LocaleModules, code: string) {
for (const [path, loader] of Object.entries(modules)) {
if (path.endsWith(`/${code}/index.json`)) {
return loader
}
}
return undefined
}
function formatIcuMessage(msg: string, locale: string, values: Record<string, unknown>) {
const cacheKey = `${locale}:${msg}`
let formatter = formatterCache.get(cacheKey)
try {
if (!formatter) {
formatter = new IntlMessageFormat(msg, locale)
formatterCache.set(cacheKey, formatter)
}
const result = formatter.format(values)
if (import.meta.dev && typeof result !== 'string') {
debug('formatIcuMessage: format returned non-string', typeof result)
}
return result as string
} catch {
return null
}
}
async function loadLocale(code: string): Promise<void> {
if (messageCache.has(code)) {
debug('loadLocale: already cached', code)
return
}
const existing = loadingPromises.get(code)
if (existing) {
debug('loadLocale: already loading', code)
return existing
}
debug('loadLocale: starting', code)
const promise = (async () => {
const websiteLoader = findLocaleLoader(websiteLocaleModules, code)
const uiLoader = findLocaleLoader(uiLocaleModules, code)
const layoutsLoader = findLocaleLoader(layoutsLocaleModules, code)
debug('loadLocale: loaders found', {
code,
website: !!websiteLoader,
ui: !!uiLoader,
layouts: !!layoutsLoader,
})
const [uiData, layoutsData, websiteData] = await Promise.all([
uiLoader?.().catch((e) => {
debug('loadLocale: ui loader failed', code, e)
return null
}),
layoutsLoader?.().catch((e) => {
debug('loadLocale: layouts loader failed', code, e)
return null
}),
websiteLoader?.().catch((e) => {
debug('loadLocale: website loader failed', code, e)
return null
}),
])
debug('loadLocale: data loaded', {
code,
uiKeys: uiData ? Object.keys(uiData.default).length : 0,
layoutsKeys: layoutsData ? Object.keys(layoutsData.default).length : 0,
websiteKeys: websiteData ? Object.keys(websiteData.default).length : 0,
})
const mergedMessages: Record<string, string> = {}
if (uiData) Object.assign(mergedMessages, transformCrowdinMessages(uiData.default))
if (layoutsData) Object.assign(mergedMessages, transformCrowdinMessages(layoutsData.default))
if (websiteData) Object.assign(mergedMessages, transformCrowdinMessages(websiteData.default))
debug('loadLocale: merged', code, 'total keys:', Object.keys(mergedMessages).length)
if (Object.keys(mergedMessages).length > 0) {
messageCache.set(code, mergedMessages)
}
})()
loadingPromises.set(code, promise)
try {
await promise
} finally {
loadingPromises.delete(code)
}
}
function parseAcceptLanguage(header: string): string | null {
try {
for (const lang of header
.split(',')
.map((l) => l.split(';')[0]?.trim())
.filter(Boolean)) {
const exact = LOCALES.find((loc) => loc.code === lang)
if (exact) return exact.code
const prefix = LOCALES.find((loc) => loc.code.startsWith(lang.split('-')[0] + '-'))
if (prefix) return prefix.code
}
} catch {
// Malformed header, ignore
}
return null
}
export default defineNuxtPlugin({
name: 'i18n',
enforce: 'pre',
async setup(nuxtApp) {
const locale = useState<string>('i18n-locale', () => DEFAULT_LOCALE)
const localeDir = computed(
() => LOCALES.find((entry) => entry.code === locale.value)?.dir ?? 'ltr',
)
function t(key: string, values?: Record<string, unknown>): string {
const currentLocale = locale.value
const localeMessages = messageCache.get(currentLocale)
const fallbackMessages = messageCache.get(DEFAULT_LOCALE)
const msg = localeMessages?.[key] ?? fallbackMessages?.[key]
if (!msg) {
debug('t: key not found', {
key,
locale: currentLocale,
hasLocaleMessages: !!localeMessages,
hasFallbackMessages: !!fallbackMessages,
})
return key
}
if (!values || Object.keys(values).length === 0) return msg
const formatted = formatIcuMessage(msg, currentLocale, values)
if (formatted !== null) return formatted
const fallbackMsg = fallbackMessages?.[key]
if (fallbackMsg && fallbackMsg !== msg) {
return formatIcuMessage(fallbackMsg, DEFAULT_LOCALE, values) ?? fallbackMsg
}
return msg
}
async function setLocale(
newLocale: string,
{ persist = true }: { persist?: boolean } = {},
): Promise<void> {
debug('setLocale: called', { newLocale, currentLocale: locale.value })
if (!LOCALES.some((l) => l.code === newLocale)) {
debug('setLocale: invalid locale', newLocale)
return
}
await loadLocale(newLocale)
debug('setLocale: loaded', {
newLocale,
cacheHas: messageCache.has(newLocale),
cacheKeys: messageCache.get(newLocale)
? Object.keys(messageCache.get(newLocale)!).length
: 0,
})
locale.value = newLocale
if (persist) {
useCookie('locale', { maxAge: 31536000, path: '/' }).value = newLocale
}
}
const cookieLocale = useCookie('locale').value
let detectedLocale = DEFAULT_LOCALE
if (cookieLocale && LOCALES.some((l) => l.code === cookieLocale)) {
detectedLocale = cookieLocale
debug('init: locale from cookie', detectedLocale)
} else if (import.meta.server) {
const acceptLang = useRequestHeaders(['accept-language'])['accept-language']
if (acceptLang) {
detectedLocale = parseAcceptLanguage(acceptLang) ?? DEFAULT_LOCALE
debug('init: locale from Accept-Language', detectedLocale)
}
}
debug('init: detected locale', { detectedLocale, cookieLocale, isServer: import.meta.server })
await loadLocale(DEFAULT_LOCALE)
if (detectedLocale !== DEFAULT_LOCALE) await loadLocale(detectedLocale)
locale.value = detectedLocale
debug('init: complete', { locale: locale.value })
useHead({
htmlAttrs: {
lang: locale,
dir: localeDir,
},
})
const context: I18nContext = { locale, t, setLocale }
nuxtApp.vueApp.provide(I18N_INJECTION_KEY, context)
},
})
+38
View File
@@ -0,0 +1,38 @@
// https://tanstack.com/query/v5/docs/framework/vue/examples/nuxt3
import type { DehydratedState, VueQueryPluginOptions } from '@tanstack/vue-query'
import { dehydrate, hydrate, QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import { defineNuxtPlugin, useState } from '#imports'
export default defineNuxtPlugin((nuxt) => {
const vueQueryState = useState<DehydratedState | null>('vue-query')
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 10000,
retry: (failureCount, error) => {
const status = (error as any)?.statusCode ?? (error as any)?.status
if (status !== undefined && status >= 400 && status < 500 && status !== 429) return false
return failureCount < 3
},
},
},
})
const options: VueQueryPluginOptions = { queryClient }
nuxt.vueApp.use(VueQueryPlugin, options)
// Expose queryClient for middleware and composables
nuxt.provide('queryClient', queryClient)
if (import.meta.server) {
nuxt.hooks.hook('app:rendered', () => {
vueQueryState.value = dehydrate(queryClient)
})
}
if (import.meta.client) {
hydrate(queryClient, vueQueryState.value)
}
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+2
View File
@@ -0,0 +1,2 @@
User-Agent: *
Disallow:
+14
View File
@@ -0,0 +1,14 @@
import preset from '@modrinth/tooling-config/tailwind/tailwind-preset.ts'
import type { Config } from 'tailwindcss'
const config: Config = {
content: [
'./src/**/*.{js,vue,ts,mdx}',
'./src/app.vue',
'../../packages/**/*.{js,vue,ts}',
'!../../packages/**/node_modules/**',
],
presets: [preset],
}
export default config
+18
View File
@@ -0,0 +1,18 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}