feat: Initial commit

This commit is contained in:
Cyper committed 2026-03-30 23:33:32 -04:00
1 parent 16004f5a84
commit 04b9739853
21 files changed
+4163

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
# Application settings
APP_PORT=
APP_ENVIRONMENT=dev
# Database settings
DB_HOST=
DB_PORT=3306
DB_USER=
DB_PASS=
DB_NAME=
# Security settings
SESSION_SECRET=
+142
View File
@@ -0,0 +1,142 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# Generated application files
public/css/site.css
# Stinky MacOS files
.DS_Store
+16
View File
@@ -0,0 +1,16 @@
import "fastify";
declare module "fastify" {
interface Session {
account: { // TODO: Type
userId: number;
username: string;
authenticated: boolean;
};
success?: string;
error?: string;
loginRedirectUri?: string;
}
}
+3114
View File
File diff suppressed because it is too large. Load diff
+47
View File
@@ -0,0 +1,47 @@
{
"name": "recypies",
"version": "1.0.0",
"description": "A simple recipe manager with no BS",
"main": "index.js",
"scripts": {
"clean": "rm -rf dist/",
"build": "npm run clean && tailwindcss -i src/css/site.css -o public/css/site.css --minify",
"dev": "concurrently \"tailwindcss -i src/css/site.css -o public/css/site.css --watch\" \"tsx watch src/app.js\"",
"start": "npm i && npm run build && tsx src/app.js"
},
"repository": {
"type": "git",
"url": "https://git.cyper.cc/Cyper/Recypies"
},
"keywords": [],
"author": "",
"license": "MIT",
"type": "module",
"devDependencies": {
"@types/ejs": "^3.1.5",
"@types/node": "^25.5.0",
"concurrently": "^9.2.1",
"pino-pretty": "^13.1.3",
"pino-print": "^1.0.0",
"tsx": "^4.21.0",
"typescript": "^6.0.2"
},
"dependencies": {
"@fastify/autoload": "^6.3.1",
"@fastify/cookie": "^11.0.2",
"@fastify/formbody": "^8.0.2",
"@fastify/multipart": "^9.4.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/session": "^11.1.1",
"@fastify/static": "^9.0.0",
"@fastify/view": "^11.1.1",
"@tailwindcss/cli": "^4.2.2",
"dotenv": "^17.3.1",
"ejs": "^5.0.1",
"fastify": "^5.8.4",
"tailwindcss": "^4.2.2"
},
"imports": {
"#/*": "./src/*.js"
}
}
+27
View File
@@ -0,0 +1,27 @@
const btn = document.getElementById('navAvatarBtn');
const dropdown = document.getElementById('navDropdown');
if (btn && dropdown) {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = dropdown.classList.toggle('open');
btn.setAttribute('aria-expanded', isOpen);
dropdown.setAttribute('aria-hidden', !isOpen);
});
document.addEventListener('click', () => {
dropdown.classList.remove('open');
btn.setAttribute('aria-expanded', false);
dropdown.setAttribute('aria-hidden', true);
});
dropdown.addEventListener('click', (e) => e.stopPropagation());
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
dropdown.classList.remove('open');
btn.setAttribute('aria-expanded', false);
btn.focus();
}
});
}
+140
View File
@@ -0,0 +1,140 @@
import path from 'path';
import ejs from 'ejs';
import Fastify, { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import Autoload from '@fastify/autoload';
import Cookie from '@fastify/cookie';
import FormBody from '@fastify/formbody';
import Multipart from '@fastify/multipart';
import RateLimit from '@fastify/rate-limit';
import Session from '@fastify/session';
import Static from '@fastify/static';
import View from '@fastify/view';
import Environment from '#/util/Environment';
const fastify = Fastify({
logger:
Environment.APP_ENVIRONMENT === 'prod'
? true
: {
level: 'info',
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'SYS:HH:MM:ss',
ignore: 'pid,hostname',
},
},
},
disableRequestLogging: true,
});
fastify.register(FormBody);
fastify.register(Multipart);
fastify.register(Cookie);
fastify.register(Static, {
root: path.join(process.cwd(), 'public'),
});
fastify.register(View, {
engine: {
ejs,
},
root: path.join(process.cwd(), 'view'),
viewExt: 'ejs',
});
fastify.register(Session, {
secret: Environment.SESSION_SECRET,
cookie: {
secure: Environment.APP_ENVIRONMENT === 'prod',
maxAge: 1000 * 60 * 60 * 24, // 1 day
},
});
await fastify.register(RateLimit, {
max: 100,
timeWindow: '1 minute',
});
fastify.setNotFoundHandler((request, reply) => {
const accept = request.headers.accept || '';
if (accept.includes('application/json')) {
// Return JSON for APIs
return reply.status(404).send({
error: 'Not Found',
message: `Requested URL '${request.url}' does not exist`,
});
} else {
// Return HTML for browsers/general users
return reply.status(404).view('status/404', { resource: request.url });
}
});
fastify.setErrorHandler(function (error, request, reply) {
const accept = request.headers.accept || '';
const sanitizedError = 'There was an internal error in the application. Please report this issue and try again later.';
const isProd = Environment.APP_ENVIRONMENT === 'prod';
if (accept.includes('application/json')) {
return reply.status(500).send({
error: 'Internal Error',
message: isProd ? sanitizedError : error,
});
} else {
return reply.view('status/500', { error: isProd ? sanitizedError : error });
}
});
await fastify.register(Autoload, {
dir: 'src/routes/public',
forceESM: true,
});
await fastify.register(async (instance: FastifyInstance) => {
instance.addHook('onRequest', (req: FastifyRequest, reply: FastifyReply, done: (error?: Error) => void) => {
if (!req.session.account?.authenticated) {
if (req.headers.accept === 'application/json') {
return reply.status(401).send({ error: 'Unauthorized' });
} else {
req.session.loginRedirectUri = encodeURI(req.url);
return reply.redirect('/login', 302);
}
}
done();
});
await instance.register(Autoload, {
dir: 'src/routes/private',
forceESM: true,
});
});
fastify.addHook('onRequest', (req: any, reply: any, done: (error?: Error) => void) => {
reply.locals = {
account: req.session.account,
environment: Environment.APP_ENVIRONMENT,
};
done();
});
fastify.listen(
{
port: Environment.APP_PORT,
host: '0.0.0.0',
},
(err: Error | null, address: string) => {
if (err) {
fastify.log.error(err);
process.exit(1);
}
fastify.log.warn(`[Web] Listening on port ${Environment.APP_PORT}`);
},
);
+310
View File
@@ -0,0 +1,310 @@
@import "tailwindcss";
:root {
--bg: #1a1714;
--surface: #2a2420;
--surface-2: #3d3228;
--border: #4a3f32;
--text: #d4c9b8;
--muted: #7a6e62;
--khaki: #a08c5e;
--grey: #8a8680;
--mint: #6ee7c7;
--mint-fg: #0d1f1a;
--radius: 0.5rem;
}
/* Base */
html,
body {
background-color: var(--bg);
color: var(--text);
min-height: 100vh;
font-family: ui-monospace, "Cascadia Code", "Fira Mono", monospace;
font-size: 0.9rem;
}
body {
display: grid;
grid-template-rows: auto 1fr auto;
}
/* Navbar */
nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--border);
min-height: 3.5rem;
}
.nav-brand {
font-size: 0.95rem;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--mint);
}
.nav-status {
display: flex;
align-items: center;
flex-direction: row;
gap: 0.75rem;
font-size: 0.78rem;
letter-spacing: 0.06em;
color: var(--muted);
}
.nav-status span {
color: var(--text);
}
.nav-status form {
display: contents;
}
/* Nav avatar & dropdown */
.nav-avatar-wrap {
position: relative;
}
.nav-avatar {
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--muted);
cursor: pointer;
display: grid;
place-items: center;
padding: 0;
transition: border-color 0.15s;
overflow: hidden;
}
.nav-avatar:hover {
border-color: var(--mint);
color: var(--mint);
}
.nav-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.nav-avatar svg {
width: 1.1rem;
height: 1.1rem;
}
.nav-dropdown {
display: none;
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
min-width: 11rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
z-index: 50;
}
.nav-dropdown.open {
display: block;
}
.nav-dropdown-header {
padding: 0.6rem 0.85rem;
border-bottom: 1px solid var(--border);
}
.nav-dropdown-username {
font-size: 0.78rem;
letter-spacing: 0.06em;
color: var(--muted);
}
.nav-dropdown-item {
display: block;
width: 100%;
text-align: left;
padding: 0.5rem 0.85rem;
font-size: 0.82rem;
font-family: inherit;
color: var(--text);
background: transparent;
border: none;
cursor: pointer;
letter-spacing: 0.04em;
text-decoration: none;
transition:
background 0.1s,
color 0.1s;
}
.nav-dropdown-item:hover {
background: var(--surface-2);
color: var(--mint);
}
.nav-dropdown-divider {
border-top: 1px solid var(--border);
margin: 0.25rem 0;
}
.nav-dropdown-signout {
color: var(--muted);
}
/* Error pages */
.error-page {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 0.75rem;
padding: 3rem 1rem;
}
.error-code {
font-size: 5rem;
font-weight: 700;
letter-spacing: 0.1em;
color: var(--border);
line-height: 1;
}
.error-title {
font-size: 1.1rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--khaki);
}
.error-message {
font-size: 0.85rem;
color: var(--muted);
max-width: 28rem;
}
.error-resource {
background: var(--surface);
border: 1px solid var(--border);
border-radius: calc(var(--radius) / 2);
padding: 0.1rem 0.4rem;
color: var(--mint);
font-size: 0.82rem;
}
.error-trace {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
font-size: 0.78rem;
color: var(--muted);
text-align: left;
max-width: 36rem;
width: 100%;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
/* Footer */
footer > div {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 0.9rem 1.5rem;
border-top: 1px solid var(--border);
font-size: 0.75rem;
letter-spacing: 0.06em;
color: var(--muted);
}
/* Card */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: calc(var(--radius) * 1.5);
padding: 2rem;
box-shadow: 6px 6px 0px var(--border);
}
/* Form field */
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.field label {
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--khaki);
}
.field input {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
padding: 0.5rem 0.75rem;
outline: none;
transition: border-color 0.15s;
font-family: inherit;
}
.field input:focus {
border-color: var(--mint);
}
/* Buttons */
.btn {
border-radius: var(--radius);
padding: 0.45rem 1.1rem;
font-size: 0.85rem;
font-family: inherit;
cursor: pointer;
border: 1px solid transparent;
transition: opacity 0.15s;
letter-spacing: 0.05em;
}
.btn:hover {
opacity: 0.8;
}
.btn-primary {
background: var(--mint);
color: var(--mint-fg);
border-color: var(--mint);
font-weight: 600;
}
.btn-secondary {
background: transparent;
color: var(--text);
border-color: var(--border);
}
/* Typography */
.page-title {
font-size: 1.4rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--khaki);
border-bottom: 1px solid var(--border);
padding-bottom: 0.6rem;
margin-bottom: 1.5rem;
}
+11
View File
@@ -0,0 +1,11 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
export default function (app: FastifyInstance) {
app.get('/', async (req: FastifyRequest, reply: FastifyReply) => {
return reply.view('home');
});
app.get('/test/url', async (req: FastifyRequest, reply: FastifyReply) => {
return reply.view('test');
});
}
+33
View File
@@ -0,0 +1,33 @@
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
export default function (app: FastifyInstance) {
app.get('/login', async (req: FastifyRequest, reply: FastifyReply) => {
if (req.session.account?.authenticated) {
return reply.redirect('/', 302);
}
return reply.view('login');
});
app.post('/login', async (req: FastifyRequest, reply: FastifyReply) => {
req.session.account = {
authenticated: true,
userId: 1,
username: 'Cyper',
};
const { loginRedirectUri } = req.session;
delete req.session.loginRedirectUri;
if (loginRedirectUri) {
return reply.redirect(loginRedirectUri, 302);
}
return reply.redirect('/', 302);
});
app.post('/logout', async (req: FastifyRequest, reply: FastifyReply) => {
req.session.destroy();
return reply.redirect('/login', 302);
});
}
+71
View File
@@ -0,0 +1,71 @@
import 'dotenv/config';
function tryParseBoolean(
value: string | boolean | undefined,
defaultValue: boolean,
): boolean {
if (value === 'true' || value === true) {
return true;
} else if (value === 'false' || value === false) {
return false;
} else {
return defaultValue;
}
}
function tryParseInt(
value: string | number | undefined,
defaultValue: number,
): number {
if (typeof value === 'number') {
return value;
}
if (typeof value !== 'string' && typeof value !== 'number') {
return defaultValue;
}
const intValue = parseInt(value);
if (!isNaN(intValue)) {
return intValue;
}
return defaultValue;
}
function tryParseString(
value: string | undefined,
defaultValue: string,
): string {
if (typeof value !== 'string') {
return defaultValue;
}
return value;
}
type Environment = {
APP_PORT: number;
APP_ENVIRONMENT: 'prod' | 'dev';
DB_HOST: string;
DB_PORT: number;
DB_USER: string;
DB_PASS: string;
DB_NAME: string;
SESSION_SECRET: string;
};
export default {
APP_PORT: tryParseInt(process.env.APP_PORT, 80),
APP_ENVIRONMENT: tryParseString(process.env.APP_ENVIRONMENT, 'prod'),
DB_HOST: tryParseString(process.env.DB_HOST, ''),
DB_PORT: tryParseInt(process.env.DB_PORT, 3306),
DB_USER: tryParseString(process.env.DB_USER, ''),
DB_PASS: tryParseString(process.env.DB_PASS, ''),
DB_NAME: tryParseString(process.env.DB_NAME, ''),
SESSION_SECRET: tryParseString(process.env.SESSION_SECRET, ''),
} as Environment;
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"rootDir": ".",
"outDir": "out",
"target": "ESNext",
"lib": ["ESNext"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"paths": {
"#/*": ["./src/*"],
},
"types": ["node"],
"sourceMap": true,
"allowJs": true,
"checkJs": false,
"strict": true,
"alwaysStrict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowUnreachableCode": true,
},
"include": ["src/**/*.ts", "src/**/*.js", "@types"],
"exclude": ["node_modules", "<node_internals>/**"],
}
+5
View File
@@ -0,0 +1,5 @@
<div>
<span>&copy; <%= (new Date()).getFullYear() %> Cyper</span>
<span style="color: var(--border)">|</span>
<span>All rights reserved</span>
</div>
+4
View File
@@ -0,0 +1,4 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title><%= typeof(title) !== "undefined" ? title + ' - ' : "" %>Template</title>
+29
View File
@@ -0,0 +1,29 @@
<nav>
<a href="/" class="nav-brand">Cyper</a>
<% if (typeof(account) !== "undefined" && account.authenticated) { %>
<div class="nav-avatar-wrap">
<button class="nav-avatar" id="navAvatarBtn" aria-haspopup="true" aria-expanded="false">
<% if (account.avatarUrl) { %>
<img src="<%= account.avatarUrl %>" alt="<%= account.username %>" />
<% } else { %>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z"/>
</svg>
<% } %>
</button>
<div class="nav-dropdown" id="navDropdown" aria-hidden="true">
<div class="nav-dropdown-header">
<span class="nav-dropdown-username"><%= account.username %></span>
</div>
<a href="/profile" class="nav-dropdown-item">Profile</a>
<a href="/settings" class="nav-dropdown-item">Settings</a>
<div class="nav-dropdown-divider"></div>
<form method="post" action="/logout">
<button class="nav-dropdown-item nav-dropdown-signout" type="submit">Sign out</button>
</form>
</div>
</div>
<% } %>
</nav>
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<%- include('_partials/header', { title: "Home" }); %>
<link rel="icon" href="/img/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/css/site.css">
</head>
<body>
<header>
<%- include("_partials/navbar"); %>
</header>
<main class="px-6 py-10">
<h1 class="page-title">Home</h1>
</main>
<footer>
<%- include("_partials/footer"); %>
</footer>
<script src="/js/navbar.js"></script>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<%- include('_partials/header', { title: "Login" }); %>
<link rel="icon" href="/img/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/css/site.css">
</head>
<body>
<header>
<%- include("_partials/navbar"); %>
</header>
<main class="grid place-items-center px-4">
<div class="card w-full max-w-sm">
<h1 class="text-lg font-semibold tracking-wide mb-6" style="color: var(--khaki)">Sign in</h1>
<form method="post" class="flex flex-col gap-4">
<div class="field">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username" />
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password" />
</div>
<div class="flex justify-end pt-2">
<button class="btn btn-primary" type="submit">Sign in</button>
</div>
</form>
</div>
</main>
<footer>
<%- include("_partials/footer"); %>
</footer>
<script src="/js/navbar.js"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<%- include('_partials/header', { title: "401 Unauthorized" }); %>
<link rel="icon" href="/img/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/css/site.css">
</head>
<body>
<header>
<%- include("_partials/navbar"); %>
</header>
<main class="grid place-items-center px-4">
<div class="error-page">
<span class="error-code">401</span>
<h1 class="error-title">Unauthorized</h1>
<p class="error-message">You need to be signed in to access this page.</p>
<a href="/login" class="btn btn-primary">Sign in</a>
</div>
</main>
<footer>
<%- include("_partials/footer"); %>
</footer>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<%- include('_partials/header', { title: "404 Not Found" }); %>
<link rel="icon" href="/img/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/css/site.css">
</head>
<body>
<header>
<%- include("_partials/navbar"); %>
</header>
<main class="grid place-items-center px-4">
<div class="error-page">
<span class="error-code">404</span>
<h1 class="error-title">Not Found</h1>
<p class="error-message">
The resource <code class="error-resource"><%= resource %></code> does not exist.
</p>
<a href="/" class="btn btn-secondary">Go home</a>
</div>
</main>
<footer>
<%- include("_partials/footer"); %>
</footer>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<%- include('_partials/header', { title: "500 Internal Server Error" }); %>
<link rel="icon" href="/img/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/css/site.css">
</head>
<body>
<header>
<%- include("_partials/navbar"); %>
</header>
<main class="grid place-items-center px-4">
<div class="error-page">
<span class="error-code">500</span>
<h1 class="error-title">Internal Server Error</h1>
<p class="error-message">Something went wrong on our end. Please try again later.</p>
<% if (environment !== 'prod' && typeof(error) !== 'undefined') { %>
<pre class="error-trace"><%= error %></pre>
<% } %>
<a href="/" class="btn btn-secondary">Go home</a>
</div>
</main>
<footer>
<%- include("_partials/footer"); %>
</footer>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<%- include('_partials/header', { title: "Home" }); %>
<link rel="icon" href="/img/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/css/site.css">
</head>
<body>
<header class="bg-red-700">
<%- include("_partials/navbar"); %>
</header>
<main class="bg-green-700">
<h1>Test Page</h1>
<form method='post' action='/logout'>
<button class="btn m-2 p-1" type="submit">Logout</button>
</form>
</main>
<footer class="bg-blue-700">
<%- include("_partials/footer"); %>
</footer>
<script src="/js/navbar.js"></script>
</body>
</html>