Implement everything

This commit is contained in:
MarcUs7i 2025-03-23 23:04:02 +01:00
parent d966dd9562
commit 30d747e046
26 changed files with 1069 additions and 6 deletions

3
.gitignore vendored
View file

@ -22,3 +22,6 @@ logs
.env
.env.*
!.env.example
# Visual Studio Code
.vscode

42
app.vue
View file

@ -1,6 +1,40 @@
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtWelcome />
</div>
<UApp>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UApp>
</template>
<script setup>
const colorMode = useColorMode()
const favicon = computed(() => {
const timestamp = Date.now()
return colorMode.value === 'dark'
? `/favicon_black.ico?t=${timestamp}`
: `/favicon.ico?t=${timestamp}`
})
useHead({
link: [
{
rel: 'icon',
type: 'image/x-icon',
href: favicon.value,
},
],
})
watch(() => colorMode.value, () => {
useHead({
link: [
{
rel: 'icon',
type: 'image/x-icon',
href: favicon.value,
},
],
})
})
</script>

88
assets/main.css Normal file
View file

@ -0,0 +1,88 @@
@import "tailwindcss";
@import "@nuxt/ui";
/* Global overrides for Nuxt UI components */
/*.nuxt-ui-button,
.nuxt-ui-link,
[class*="nuxt-ui-button"],
[class*="nuxt-ui-link"] {
cursor: default !important;
}*/
/* For UButton and ULink without nuxt-ui classes */
/*button, a {
cursor: default !important;
}*/
.prose {
max-width: 65ch;
margin: 0 auto;
padding: 1rem;
}
.prose h1 {
font-size: 2.25rem;
font-weight: bold;
margin-bottom: 1rem;
}
.prose h2 {
font-size: 1.5rem;
font-weight: bold;
margin-top: 2rem;
margin-bottom: 1rem;
}
/* Remove link styling from headings in Markdown content */
.prose h2 a,
.prose h3 a,
.prose h4 a,
.prose h5 a,
.prose h6 a {
color: inherit;
text-decoration: none;
}
.prose h2 a:hover,
.prose h3 a:hover,
.prose h4 a:hover,
.prose h5 a:hover,
.prose h6 a:hover {
color: inherit;
text-decoration: none;
}
/* Hide the # link icon that might appear on hover */
.prose h2 a::after,
.prose h3 a::after,
.prose h4 a::after,
.prose h5 a::after,
.prose h6 a::after {
display: none;
}
.prose p {
margin-bottom: 1rem;
line-height: 1.6;
}
.prose a {
color: #1d4ed8;
text-decoration: underline;
}
.prose a:hover {
color: #2563eb;
}
.dark .prose {
color: #e5e7eb;
}
.dark .prose a {
color: #60a5fa;
}
.dark .prose a:hover {
color: #93c5fd;
}

15
components/AppFooter.vue Normal file
View file

@ -0,0 +1,15 @@
<template>
<footer class="bg-gray-800 text-white p-4">
<div class="flex justify-between items-center px-4 sm:px-6 md:px-8">
<div>© {{ new Date().getFullYear() }} <ULink to="/" class="text-white cursor-default">MarcUs7i.Net</ULink>. All rights reserved.</div>
<div class="flex gap-4">
<ULink to="/discord" target="_blank">Discord</ULink>
<ULink v-if="config.siteLinks.github" :to="config.siteLinks.github" target="_blank">GitHub</ULink>
</div>
</div>
</footer>
</template>
<script setup>
const { config } = useSiteConfig()
</script>

19
components/NavBar.vue Normal file
View file

@ -0,0 +1,19 @@
<template>
<header class="bg-gray-800 text-white p-4">
<nav class="flex justify-between items-center">
<div class="logo">
<ULink as="button" to="/" class="text-xl text-white cursor-default">MarcUs7i.Net</ULink>
</div>
<ul class="flex gap-4">
<li><ULink to="/about">About</ULink></li>
<li><ULink to="/contact">Contact</ULink></li>
<li v-if="config.siteLinks.github"><ULink :to="config.siteLinks.github" target="_blank"><UIcon name="i-simple-icons-github" class="size-5" /></ULink></li>
<li><ULink to="/discord" target="_blank"><UIcon name="i-simple-icons-discord" class="size-5" /></ULink></li>
</ul>
</nav>
</header>
</template>
<script setup>
const { config } = useSiteConfig()
</script>

View file

@ -0,0 +1,77 @@
import { ref, reactive, onMounted } from 'vue'
// Shared state to prevent multiple toasts
let toastShown = false
export function useSiteConfig() {
const config = reactive({
siteLinks: {},
buttons: []
})
const isLoading = ref(true)
const error = ref(null)
const toast = useToast()
async function loadConfig() {
try {
toastShown = false
isLoading.value = true
const response = await fetch('/site-config.json')
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`)
}
const data = await response.json()
if (!data['site-links'] && !data.buttons) {
throw new Error('Invalid configuration format: Missing required fields')
}
config.siteLinks = data['site-links'] || {}
config.buttons = data.buttons || []
error.value = null
} catch (err) {
console.error('Failed to load site configuration:', err)
error.value = err
let errorMessage = 'Failed to load site configuration. Please try again later.'
if (err.message.includes('HTTP error')) {
const status = err.message.match(/\d+/) ? err.message.match(/\d+/)[0] : 'unknown'
errorMessage = `Server returned ${status} error. Please check if the configuration file exists.`
} else if (err.name === 'SyntaxError') {
errorMessage = 'Invalid JSON format in configuration file.'
} else if (err.message.includes('Invalid configuration format')) {
errorMessage = err.message
}
if (!toastShown) {
toast.add({
title: 'Configuration Error',
description: errorMessage,
icon: 'i-lucide-alert-triangle',
color: 'error',
timeout: 5000
})
toastShown = true
}
} finally {
isLoading.value = false
}
}
onMounted(() => {
loadConfig()
})
return {
config,
isLoading,
error,
reload: loadConfig
}
}

17
content.config.ts Normal file
View file

@ -0,0 +1,17 @@
import { defineCollection, defineContentConfig, z } from '@nuxt/content'
export default defineContentConfig({
collections: {
/**
* This is collection for content-wind theme
* Create `content.config.ts` in project root to overwrite this
*/
content: defineCollection({
type: 'page',
source: '**',
schema: z.object({
layout: z.string(),
}),
}),
},
})

17
content/about.md Normal file
View file

@ -0,0 +1,17 @@
---
title: 'About'
description: 'about page of marcus7i.net'
---
# About MarcUs7i.Net
This site hosts multiple services and applications, mostly created, some improved by MarcUs7i
## Donations
You can support me by donating to my [Ko-fi](https://ko-fi.com/marcus7i).
## Contact
Feel free to [get in touch](/discord) on discord if you'd like to collaborate on a project.<br>
If discord is a no-go, you can also simply [contact](/contact) via the website.

9
layouts/default.vue Normal file
View file

@ -0,0 +1,9 @@
<template>
<div class="min-h-screen flex flex-col">
<NavBar />
<main class="flex-grow container mx-auto px-4 py-8">
<slot />
</main>
<AppFooter />
</div>
</template>

View file

@ -1,6 +1,5 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2024-11-01',
devtools: { enabled: true },
modules: [
@ -12,5 +11,29 @@ export default defineNuxtConfig({
'@nuxt/scripts',
'@nuxt/test-utils',
'@nuxt/ui'
]
],
css: [
"~/assets/main.css"
],
colorMode: {
preference: 'system',
fallback: 'dark',
classSuffix: '',
},
app: {
head: {
title: 'MarcUs7i.Net',
meta: [
{ name: 'description', content: 'The official site for MarcUs7i.Net' }
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
]
}
},
compatibilityDate: '2025-03-22',
})

107
package-lock.json generated
View file

@ -15,12 +15,23 @@
"@nuxt/scripts": "^0.11.2",
"@nuxt/test-utils": "^3.17.2",
"@nuxt/ui": "^3.0.1",
"@tailwindcss/postcss": "^4.0.15",
"@tailwindcss/vite": "^4.0.15",
"@unhead/vue": "^2.0.0-rc.8",
"animate.css": "^4.1.1",
"eslint": "^9.23.0",
"nuxt": "^3.16.1",
"sass": "^1.86.0",
"tailwindcss": "^4.0.15",
"typescript": "^5.8.2",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@iconify-json/material-symbols": "^1.2.17",
"@iconify-json/simple-icons": "^1.2.29",
"@nuxtjs/mdc": "^0.16.1",
"@tailwindcss/typography": "^0.5.16"
}
},
"node_modules/@alloc/quick-lru": {
@ -1432,6 +1443,26 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@iconify-json/material-symbols": {
"version": "1.2.17",
"resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.17.tgz",
"integrity": "sha512-hKb+Ii5cqLXXefYMxUB2jIc8BNqxixQogud4KU/fn0F4puM1iCdCF2lFV+0U8wnJ6dZIx6E+w8Ree4bIT7To+A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify-json/simple-icons": {
"version": "1.2.29",
"resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.29.tgz",
"integrity": "sha512-KYrxmxtRz6iOAulRiUsIBMUuXek+H+Evwf8UvYPIkbQ+KDoOqTegHx3q/w3GDDVC0qJYB+D3hXPMZcpm78qIuA==",
"dev": true,
"license": "CC0-1.0",
"dependencies": {
"@iconify/types": "*"
}
},
"node_modules/@iconify/collections": {
"version": "1.0.530",
"resolved": "https://registry.npmjs.org/@iconify/collections/-/collections-1.0.530.tgz",
@ -3952,6 +3983,36 @@
"tailwindcss": "4.0.15"
}
},
"node_modules/@tailwindcss/typography": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",
"integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"lodash.castarray": "^4.4.0",
"lodash.isplainobject": "^4.0.6",
"lodash.merge": "^4.6.2",
"postcss-selector-parser": "6.0.10"
},
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
},
"engines": {
"node": ">=4"
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.0.15",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.0.15.tgz",
@ -5038,6 +5099,12 @@
"integrity": "sha512-aITl4ODHNX9mqBqwZWr5oTYP74hemqVGV4KRLSQacjoZIdwNxbedHF656+c4zuGLtRtcowitoXdIfyrXgzniVg==",
"license": "MIT"
},
"node_modules/animate.css": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/animate.css/-/animate.css-4.1.1.tgz",
"integrity": "sha512-+mRmCTv6SbCmtYJCN4faJMNFVNN5EuCTTprDTAo7YzIGji2KADmakjVA3+8mVDkZ2Bf09vayB35lSQIex2+QaQ==",
"license": "MIT"
},
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
@ -8812,6 +8879,12 @@
"integrity": "sha512-K6acvFaelNxx8wc2VjbIzXKDVB0Khs0QT35U6NkGfTdCmjLNcO2945m7RFNR9/RPVFm48hq7QPzK8uGH18HCGw==",
"license": "MIT"
},
"node_modules/immutable": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz",
"integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==",
"license": "MIT"
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -9824,6 +9897,13 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.castarray": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
"integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
@ -9836,6 +9916,13 @@
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"dev": true,
"license": "MIT"
},
"node_modules/lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@ -13334,6 +13421,26 @@
],
"license": "MIT"
},
"node_modules/sass": {
"version": "1.86.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.86.0.tgz",
"integrity": "sha512-zV8vGUld/+mP4KbMLJMX7TyGCuUp7hnkOScgCMsWuHtns8CWBoz+vmEhoGMXsaJrbUP8gj+F1dLvVe79sK8UdA==",
"license": "MIT",
"dependencies": {
"chokidar": "^4.0.0",
"immutable": "^5.0.2",
"source-map-js": ">=0.6.2 <2.0.0"
},
"bin": {
"sass": "sass.js"
},
"engines": {
"node": ">=14.0.0"
},
"optionalDependencies": {
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/scslre": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/scslre/-/scslre-0.3.0.tgz",

View file

@ -18,11 +18,22 @@
"@nuxt/scripts": "^0.11.2",
"@nuxt/test-utils": "^3.17.2",
"@nuxt/ui": "^3.0.1",
"@tailwindcss/postcss": "^4.0.15",
"@tailwindcss/vite": "^4.0.15",
"@unhead/vue": "^2.0.0-rc.8",
"animate.css": "^4.1.1",
"eslint": "^9.23.0",
"nuxt": "^3.16.1",
"sass": "^1.86.0",
"tailwindcss": "^4.0.15",
"typescript": "^5.8.2",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@iconify-json/material-symbols": "^1.2.17",
"@iconify-json/simple-icons": "^1.2.29",
"@nuxtjs/mdc": "^0.16.1",
"@tailwindcss/typography": "^0.5.16"
}
}

View file

@ -0,0 +1,25 @@
<script lang="ts" setup>
const route = useRoute()
const { data: page } = await useAsyncData(route.path, () => {
return queryCollection('content').path(route.path).first()
})
useHead(() => ({
title: page.value?.title || 'About'
}))
</script>
<template>
<template v-if="page">
<div>
<ContentRenderer :value="page.body" class="prose dark:prose-invert" />
</div>
</template>
<template v-else>
<div class="empty-page">
<h1>404 Page Not Found</h1>
<p>Oops! The content you're looking for doesn't exist.</p>
<ULink to="/">Go back home</ULink>
</div>
</template>
</template>

144
pages/contact.vue Normal file
View file

@ -0,0 +1,144 @@
<template>
<div class="flex flex-col items-center justify-center py-12 px-4">
<h1 class="text-3xl font-bold mb-12">Contact Me</h1>
<div class="w-full max-w-2xl">
<UForm
:schema="schema"
:state="state"
class="space-y-8 bg-gray-800 p-8 rounded-lg shadow-lg"
@submit="onSubmit"
>
<UFormField
label="Email Address"
name="email"
required
class="mb-8"
>
<UInput
v-model="state.email"
placeholder="your.email@example.com"
size="lg"
class="w-full"
/>
</UFormField>
<UFormField
label="Message Subject"
name="subject"
required
class="mb-8"
>
<UInput
v-model="state.subject"
placeholder="What is this about?"
size="xl"
class="w-full text-lg"
/>
</UFormField>
<UFormField
label="Your Message"
name="message"
required
class="mb-8"
>
<UTextarea
v-model="state.message"
placeholder="Your message here..."
:rows="8"
size="lg"
class="w-full"
/>
</UFormField>
<div class="flex justify-end mt-10">
<UButton
type="submit"
:loading="isSubmitting"
size="lg"
color="primary"
class="px-8 py-2"
>
Send Message
</UButton>
</div>
</UForm>
</div>
</div>
</template>
<script setup lang="ts">
useHead({
title: 'Contact'
})
import { z } from 'zod'
import type { FormSubmitEvent } from '@nuxt/ui'
const schema = z.object({
email: z.string().email({ message: 'Please enter a valid email address' }).min(1, { message: 'Email is required' }),
subject: z.string().min(3, { message: 'Subject must be at least 3 characters' }),
message: z.string().min(10, { message: 'Message must be at least 10 characters' })
})
type Schema = z.infer<typeof schema>
const state = reactive({
email: '',
subject: '',
message: ''
})
const isSubmitting = ref(false)
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
try {
isSubmitting.value = true
const discordMessage = {
embeds: [{
title: `Contact Form: ${state.subject}`,
description: state.message,
color: 3447003, // Blue
fields: [
{
name: 'Email',
value: state.email
}
],
timestamp: new Date().toISOString()
}]
}
await $fetch('/api/send-contact', {
method: 'POST',
body: {
message: discordMessage
}
})
toast.add({
title: 'Message Sent!',
description: 'Your message has been sent successfully.',
icon: 'i-heroicons-check-circle',
color: 'success'
})
state.email = ''
state.subject = ''
state.message = ''
} catch (error) {
toast.add({
title: 'Error',
description: 'Something went wrong. Please try again.',
icon: 'i-heroicons-exclamation-circle',
color: 'error'
})
} finally {
isSubmitting.value = false
}
}
</script>

View file

62
pages/discord.vue Normal file
View file

@ -0,0 +1,62 @@
<template>
<div class="flex flex-col items-center justify-center min-h-screen px-4">
<!-- Title -->
<h1 class="text-2xl font-bold mb-8">Redirecting to Discord...</h1>
<!-- Progress bar -->
<div class="w-full max-w-md mb-12">
<UProgress v-model="progress" color="primary" size="lg" />
<p class="text-center mt-2 text-sm text-gray-500">
Redirecting in {{ Math.ceil(remainingTime) }} seconds...
</p>
</div>
<UButton
size="xl"
:to="discordLink"
class="mt-4 text-lg px-8 py-4"
icon="i-simple-icons-discord"
>
Don't wait - Join Now
</UButton>
</div>
</template>
<script setup>
useHead({
title: 'Discord'
})
const { config } = useSiteConfig()
const discordLink = ref(config.siteLinks['discord-invite'] || 'https://discord.gg/e37aq2wc66')
// Progress state
const progress = ref(0)
const totalTime = 3 // seconds
const remainingTime = ref(totalTime)
const interval = 50 // ms
let timer
onMounted(() => {
const startTime = Date.now()
timer = setInterval(() => {
const elapsed = (Date.now() - startTime) / 1000
remainingTime.value = Math.max(0, totalTime - elapsed)
const rawProgress = (elapsed / totalTime) * 100
progress.value = Math.min(100, Math.max(0, Math.round(rawProgress)))
if (elapsed >= totalTime) {
clearInterval(timer)
window.location.href = discordLink.value
}
}, interval)
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
</script>

142
pages/games.vue Normal file
View file

@ -0,0 +1,142 @@
<template>
<div class="flex flex-col items-center justify-center gap-8 pt-8">
<h1 class="font-bold text-5xl text-[--ui-primary] mt-10">
Games
</h1>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl">
<UCard
v-for="game in games"
:key="game.title"
class="bg-gray-900 text-white shadow-lg"
:ui="{
root: 'bg-gray-900 text-white flex flex-col h-full shadow-lg rounded-lg ring-1 ring-gray-700',
header: 'p-4 pb-0',
body: 'flex-grow p-4 pt-2',
footer: 'p-4 pt-0'
}"
>
<!-- Game Logo and Status -->
<div class="flex items-center justify-between">
<img
:src="getImageSrc(game)"
alt="Game Logo"
class="w-12 h-12 object-contain"
@error="handleImageError"
/>
<UBadge
:color="getBadgeColor(game.status)"
variant="soft"
size="sm"
class="font-semibold"
>
{{ game.status }}
</UBadge>
</div>
<!-- Game Title -->
<h2 class="text-xl font-bold mt-4">{{ game.title }}</h2>
<!-- Game Description -->
<p class="text-gray-400 mt-2">{{ game.description }}</p>
<!-- Separator -->
<USeparator class="my-3" />
<!-- Game Links -->
<div class="flex gap-4 mt-2">
<a
v-for="link in game.url || []"
:key="link.url"
:href="link.url"
target="_blank"
class="text-blue-400 hover:text-blue-300 flex items-center gap-2 transition-colors"
>
<UIcon :name="link.logo" class="text-lg" />
<span class="text-sm">{{ link.host }}</span>
</a>
</div>
</UCard>
</div>
</div>
</template>
<script lang="ts" setup>
import siteConfig from '~/public/site-config.json'
import { ref, onMounted } from 'vue'
const games = siteConfig.games
const imageCache = ref(new Map())
function getBadgeColor(status: string) {
if (status === 'Released') return 'success'
if (status === 'Abandoned') return 'error'
return 'warning'
}
// Check if image exists and cache result
function getImageSrc(game: { [x: string]: any }) {
// During SSR, just return the URL or fallback
if (typeof window === 'undefined') {
return game['logo-url'] || '/cancel.svg'
}
const url = game['logo-url']
// If no URL or already checked and failed, use cancel.svg
if (!url || imageCache.value.get(url) === false) {
return '/cancel.svg'
}
// If not checked yet, check it now
if (!imageCache.value.has(url)) {
checkImage(url)
}
return url
}
// Function to check if image exists
function checkImage(url: string | null) {
// Skip this function if not in browser or url is null
if (typeof window === 'undefined' || url === null) return
const img = new window.Image()
img.onload = () => {
imageCache.value.set(url, true)
}
img.onerror = () => {
imageCache.value.set(url, false)
// Force a component update for this URL
const affectedGames = games.filter(g => g['logo-url'] === url)
if (affectedGames.length) {
// This will trigger a re-render
imageCache.value = new Map(imageCache.value)
}
}
img.src = url
}
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement
img.src = '/cancel.svg'
// Also cache this failure for future renders
if (img.dataset.originalSrc) {
imageCache.value.set(img.dataset.originalSrc, false)
}
}
// Pre-check all images when component is mounted
onMounted(() => {
games.forEach(game => {
if (game['logo-url']) {
checkImage(game['logo-url'])
}
})
})
useHead({
title: 'Games'
})
</script>

View file

@ -0,0 +1,101 @@
<template>
<div class="flex flex-col items-center justify-center gap-8 pt-8">
<h1 class="font-bold text-5xl text-(--ui-primary) mt-10">
MarcUs7i.Net
</h1>
<div class="flex items-center gap-2 mt-6">
<UButtonGroup>
<UButton
label="Discord"
icon="i-simple-icons-discord"
to="/discord"
target="_blank"
/>
<UButton
v-if="config.siteLinks.github"
label="GitHub"
color="neutral"
variant="outline"
icon="i-simple-icons-github"
:to="config.siteLinks.github"
target="_blank"
/>
<UButton
v-if="config.siteLinks['status-page']"
label="Status"
color="neutral"
icon="i-simple-icons-statuspage"
:to="config.siteLinks['status-page']"
target="_blank"
/>
</UButtonGroup>
</div>
<!-- 2x2 Grid of Smaller Buttons -->
<div class="grid grid-cols-2 gap-4 pt-16 max-w-3xl">
<UButton
v-for="button in config.buttons"
:key="button.url"
class="big-button"
:to="button.url"
size="lg"
>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-3">
<UIcon :name="button.icon" class="w-5 h-5 text-green-500" />
<span class="label">{{ button.title }}</span>
</div>
<span v-if="button.description" class="description">
{{ button.description }}
</span>
</div>
</UButton>
</div>
</div>
</template>
<script setup>
useHead({
title: 'Marcus7i.Net'
})
const { config } = useSiteConfig()
</script>
<style>
.big-button {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
padding: 1.5rem; /* p-6 */
min-height: 3.5rem; /* h-48 */
width: 100%;
text-align: left;
border-radius: 0.5rem; /* rounded-lg */
border: 1px solid rgb(55, 65, 81); /* border border-gray-700 */
background-color: rgb(31, 41, 55); /* bg-gray-800 */
color: white;
transition: all 0.3s;
}
.big-button:hover {
border-color: rgb(34, 197, 94); /* border-green-500 */
background-color: rgb(17, 24, 39); /* bg-gray-900 */
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); /* shadow-lg */
}
.label {
font-size: 1.125rem; /* text-lg */
font-weight: 600; /* font-semibold */
color: white;
}
.description {
font-size: 0.875rem; /* text-sm */
color: rgb(156, 163, 175); /* text-gray-400 */
}
</style>

24
pages/maintenance.vue Normal file
View file

@ -0,0 +1,24 @@
<template>
<div class="flex flex-col items-center justify-center gap-30 pt-20">
<h1 class="font-bold text-5xl text-(--ui-primary) mt-10">
This site is in maintenance!
</h1>
<div class="flex items-center gap-2 mt-16">
<UButton
label="Return Home"
variant="outline"
color="neutral"
to="/"
size="xl"
class="px-8 text-lg"
/>
</div>
</div>
</template>
<script setup>
useHead({
title: 'Maintenance'
})
</script>

View file

1
public/cancel.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Material Line Icons by Vjacheslav Trushkin - https://github.com/cyberalien/line-md/blob/master/license.txt --><g fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path stroke-dasharray="64" stroke-dashoffset="64" d="M5.64 5.64c3.51 -3.51 9.21 -3.51 12.73 0c3.51 3.51 3.51 9.21 0 12.73c-3.51 3.51 -9.21 3.51 -12.73 0c-3.51 -3.51 -3.51 -9.21 -0 -12.73Z"><animate fill="freeze" attributeName="stroke-dashoffset" dur="0.6s" values="64;0"/></path><path stroke-dasharray="20" stroke-dashoffset="20" d="M6 6l12 12"><animate fill="freeze" attributeName="stroke-dashoffset" begin="0.6s" dur="0.2s" values="20;0"/></path></g></svg>

After

Width:  |  Height:  |  Size: 763 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

91
public/site-config.json Normal file
View file

@ -0,0 +1,91 @@
{
"site-links":
{
"discord-invite": "https://discord.gg/e37aq2wc66",
"github": "https://github.com/MarcUs7i",
"status-page": "https://status.marcus7i.net"
},
"buttons": [
{
"title": "Open-WebUI",
"url": "https://ollama.marcus7i.net",
"icon": "i-simple-icons-ollama",
"description": "Self-hosted WebUI for LLMs using Ollama"
},
{
"title": "uLinkShortener",
"url": "https://u.marcus7i.net",
"icon": "line-md:link",
"description": "URL shortener and data collector"
},
{
"title": "Git",
"url": "https://git.marcus7i.net",
"icon": "i-simple-icons-git",
"description": ""
},
{
"title": "Games",
"url": "/games",
"icon": "line-md:play-filled",
"description": ""
},
{
"title": "Anywave",
"url": "/maintenance",
"icon": "i-simple-icons-stremio",
"description": ""
},
{
"title": "SauceKudasai",
"url": "https://saucekudasai.marcus7i.net",
"icon": "i-simple-icons-sunrise",
"description": ""
}
],
"games": [
{
"title": "Shanti Manti",
"description": "Shanti Manti has to fight against his classmates to survive.",
"status": "Released",
"logo-url": "/game-icons/shantimanti.png",
"url": [
{
"host": "GitHub",
"logo": "i-simple-icons-github",
"url": "https://github.com/MarcUs7i/ShantiManti"
},
{
"host": "Itch",
"logo": "i-simple-icons-itchdotio",
"url": "https://marcus7i.itch.io/shanti-manti"
}
]
},
{
"title": "SynthMaze",
"description": "You have to solve the Mazes to escape from the Enemy's headquarter.",
"status": "Abandoned",
"logo-url": "/game-icons/synthmaze.png",
"url": [
{
"host": "GitHub",
"logo": "i-simple-icons-github",
"url": "https://github.com/MarcUs7i/SynthMaze"
},
{
"host": "Itch",
"logo": "i-simple-icons-itchdotio",
"url": "https://marcus7i.itch.io/synthmaze"
}
]
},
{
"title": "TetrisRemastered",
"description": "Tetris in 2D & 3D with customizable music engine",
"status": "In development",
"logo-url": "/game-icons/tetrisremastered.png",
"leaderboard-api": "http://localhost:3001/leaderboard"
}
]
}

View file

@ -0,0 +1,35 @@
export default defineEventHandler(async (event) => {
try {
const body = await readBody(event)
const { message } = body
const webhookUrl = process.env.DISCORD_WEBHOOK
if (!webhookUrl) {
throw new Error('Discord webhook URL is not configured')
}
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(message)
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Discord API error: ${response.status} - ${errorText}`)
}
return { success: true }
} catch (error) {
console.error('Failed to send message to Discord:', error)
return createError({
statusCode: 500,
statusMessage: 'Failed to send message',
message: error.message || 'An unknown error occurred'
})
}
})

18
tailwind.config.js Normal file
View file

@ -0,0 +1,18 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./components/**/*.{js,vue,ts}",
"./layouts/**/*.vue",
"./pages/**/*.vue",
"./plugins/**/*.{js,ts}",
"./app.vue",
"./error.vue",
"./content/**/*.md",
],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/typography'),
],
}