Compare commits
2 Commits
feat/blog
...
feat/prici
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f92150cb9e | ||
|
|
c0c14d8c3c |
24
apps/blog/.gitignore
vendored
@@ -1,24 +0,0 @@
|
||||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
4
apps/blog/.vscode/extensions.json
vendored
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode", "unifiedjs.vscode-mdx"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
11
apps/blog/.vscode/launch.json
vendored
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
# Astro Starter Kit: Blog
|
||||
|
||||
```sh
|
||||
bun create astro@latest -- --template blog
|
||||
```
|
||||
|
||||
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/blog)
|
||||
[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/blog)
|
||||
[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/blog/devcontainer.json)
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
|
||||

|
||||
|
||||
Features:
|
||||
|
||||
- ✅ Minimal styling (make it your own!)
|
||||
- ✅ 100/100 Lighthouse performance
|
||||
- ✅ SEO-friendly with canonical URLs and OpenGraph data
|
||||
- ✅ Sitemap support
|
||||
- ✅ RSS Feed support
|
||||
- ✅ Markdown & MDX support
|
||||
|
||||
## 🚀 Project Structure
|
||||
|
||||
Inside of your Astro project, you'll see the following folders and files:
|
||||
|
||||
```text
|
||||
├── public/
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ ├── content/
|
||||
│ ├── layouts/
|
||||
│ └── pages/
|
||||
├── astro.config.mjs
|
||||
├── README.md
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
|
||||
|
||||
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
|
||||
|
||||
The `src/content/` directory contains "collections" of related Markdown and MDX documents. Use `getCollection()` to retrieve posts from `src/content/blog/`, and type-check your frontmatter using an optional schema. See [Astro's Content Collections docs](https://docs.astro.build/en/guides/content-collections/) to learn more.
|
||||
|
||||
Any static assets, like images, can be placed in the `public/` directory.
|
||||
|
||||
## 🧞 Commands
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `bun install` | Installs dependencies |
|
||||
| `bun dev` | Starts local dev server at `localhost:4321` |
|
||||
| `bun build` | Build your production site to `./dist/` |
|
||||
| `bun preview` | Preview your build locally, before deploying |
|
||||
| `bun astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `bun astro -- --help` | Get help using the Astro CLI |
|
||||
|
||||
## 👀 Want to learn more?
|
||||
|
||||
Check out [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||
|
||||
## Credit
|
||||
|
||||
This theme is based off of the lovely [Bear Blog](https://github.com/HermanMartinus/bearblog/).
|
||||
@@ -1,18 +0,0 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import mdx from '@astrojs/mdx';
|
||||
import sitemap from '@astrojs/sitemap';
|
||||
|
||||
import solidJs from '@astrojs/solid-js';
|
||||
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://example.com',
|
||||
integrations: [mdx(), sitemap(), solidJs()],
|
||||
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
});
|
||||
1022
apps/blog/bun.lock
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^4.2.6",
|
||||
"@astrojs/rss": "^4.0.11",
|
||||
"@astrojs/sitemap": "^3.4.0",
|
||||
"@astrojs/solid-js": "^5.0.10",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"astro": "^5.7.13",
|
||||
"solid-js": "^1.9.7",
|
||||
"tailwindcss": "^4.1.7"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 21 KiB |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||
<style>
|
||||
path { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 749 B |
|
Before Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 157 KiB |
@@ -1,55 +0,0 @@
|
||||
---
|
||||
// Import the global.css file here so that it is included on
|
||||
// all pages through the use of the <BaseHead /> component.
|
||||
import '../styles/global.css';
|
||||
import { SITE_TITLE } from '../consts';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||
|
||||
const { title, description, image = '/blog-placeholder-1.jpg' } = Astro.props;
|
||||
---
|
||||
|
||||
<!-- Global Metadata -->
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="sitemap" href="/sitemap-index.xml" />
|
||||
<link
|
||||
rel="alternate"
|
||||
type="application/rss+xml"
|
||||
title={SITE_TITLE}
|
||||
href={new URL('rss.xml', Astro.site)}
|
||||
/>
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<!-- Font preloads -->
|
||||
<link rel="preload" href="/fonts/atkinson-regular.woff" as="font" type="font/woff" crossorigin />
|
||||
<link rel="preload" href="/fonts/atkinson-bold.woff" as="font" type="font/woff" crossorigin />
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href={canonicalURL} />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>{title}</title>
|
||||
<meta name="title" content={title} />
|
||||
<meta name="description" content={description} />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={Astro.url} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={new URL(image, Astro.url)} />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content={Astro.url} />
|
||||
<meta property="twitter:title" content={title} />
|
||||
<meta property="twitter:description" content={description} />
|
||||
<meta property="twitter:image" content={new URL(image, Astro.url)} />
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
import "../styles/global.css"
|
||||
const today = new Date();
|
||||
---
|
||||
|
||||
<footer>
|
||||
<div class="mt-6 flex w-full items-center justify-center gap-2 text-xs sm:text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
<span class="hover:text-primary-500 transition-colors duration-200">
|
||||
<a rel="noreferrer" href="https://nestri.io/terms" >Terms of Service</a></span>
|
||||
<span class="text-gray-400 dark:text-gray-600">•</span>
|
||||
<span class="hover:text-primary-500 transition-colors duration-200">
|
||||
<a href="https://nestri.io/privacy">Privacy Policy</a>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-6 w-full justify-center flex items-center space-x-4">
|
||||
<a href="https://discord.gg/6um5K6jrYj" target="_blank">
|
||||
<span class="sr-only">Join our Discord Server</span>
|
||||
<svg width="59" height="44" viewBox="0 0 59 44" aria-hidden="true" astro-icon="social/discord" style="height:28px">
|
||||
<path d="M37.1937 0C36.6265 1.0071 36.1172 2.04893 35.6541 3.11392C31.2553 2.45409 26.7754 2.45409 22.365 3.11392C21.9136 2.04893 21.3926 1.0071 20.8254 0C16.6928 0.70613 12.6644 1.94475 8.84436 3.69271C1.27372 14.9098 -0.775214 25.8374 0.243466 36.6146C4.67704 39.8906 9.6431 42.391 14.9333 43.9884C16.1256 42.391 17.179 40.6893 18.0819 38.9182C16.3687 38.2815 14.7133 37.4828 13.1274 36.5567C13.5442 36.2557 13.9493 35.9432 14.3429 35.6422C23.6384 40.0179 34.4039 40.0179 43.711 35.6422C44.1046 35.9663 44.5097 36.2789 44.9264 36.5567C43.3405 37.4943 41.6852 38.2815 39.9604 38.9298C40.8633 40.7009 41.9167 42.4025 43.109 44C48.3992 42.4025 53.3653 39.9137 57.7988 36.6377C59.0027 24.1358 55.7383 13.3007 49.1748 3.70429C45.3663 1.95633 41.3379 0.717706 37.2053 0.0231518L37.1937 0ZM19.3784 29.9816C16.5192 29.9816 14.1461 27.3886 14.1461 24.1821C14.1461 20.9755 16.4266 18.371 19.3669 18.371C22.3071 18.371 24.6455 20.9871 24.5992 24.1821C24.5529 27.377 22.2956 29.9816 19.3784 29.9816ZM38.6639 29.9816C35.7931 29.9816 33.4431 27.3886 33.4431 24.1821C33.4431 20.9755 35.7236 18.371 38.6639 18.371C41.6042 18.371 43.9309 20.9871 43.8846 24.1821C43.8383 27.377 41.581 29.9816 38.6639 29.9816Z" fill="white"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a href="https://github.com/nestrilabs/nestri/" target="_blank">
|
||||
<span class="sr-only">Go to Nestri's GitHub repo</span>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32" astro-icon="social/github"
|
||||
><path
|
||||
fill="currentColor"
|
||||
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
<style>
|
||||
footer {
|
||||
padding: 2em 1em 6em 1em;
|
||||
background: linear-gradient(var(--gray-gradient)) no-repeat;
|
||||
color: rgb(var(--gray));
|
||||
text-align: center;
|
||||
}
|
||||
.social-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1em;
|
||||
margin-top: 1em;
|
||||
}
|
||||
.social-links a {
|
||||
text-decoration: none;
|
||||
color: rgb(var(--gray));
|
||||
}
|
||||
.social-links a:hover {
|
||||
color: rgb(var(--gray-dark));
|
||||
}
|
||||
</style>
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
interface Props {
|
||||
date: Date;
|
||||
}
|
||||
|
||||
const { date } = Astro.props;
|
||||
---
|
||||
|
||||
<time datetime={date.toISOString()}>
|
||||
{
|
||||
date.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
</time>
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
import HeaderLink from './HeaderLink.astro';
|
||||
import { SITE_TITLE } from '../consts';
|
||||
import "../styles/global.css";
|
||||
---
|
||||
|
||||
<header>
|
||||
<nav>
|
||||
<h2><a href="/">{SITE_TITLE}</a></h2>
|
||||
<div class="internal-links">
|
||||
<HeaderLink href="https://nestri.io/">Nestri Home</HeaderLink>
|
||||
<HeaderLink href="/blog">Blog</HeaderLink>
|
||||
<HeaderLink href="https://nestri.io/about">About us</HeaderLink>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<style>
|
||||
header {
|
||||
margin: 0;
|
||||
padding: 0 1em;
|
||||
border-bottom: solid;
|
||||
box-: 0 2px 8px rgba(var(--black), 5%);
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
h2 a,
|
||||
h2 a.active {
|
||||
text-decoration: none;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
nav a {
|
||||
padding: 1em 0.5em;
|
||||
color: var(--black);
|
||||
border-bottom: 4px solid transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
nav a.active {
|
||||
text-decoration: none;
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.social-links,
|
||||
.social-links a {
|
||||
display: flex;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.social-links {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
|
||||
type Props = HTMLAttributes<'a'>;
|
||||
|
||||
const { href, class: className, ...props } = Astro.props;
|
||||
const pathname = Astro.url.pathname.replace(import.meta.env.BASE_URL, '');
|
||||
const subpath = pathname.match(/[^\/]+/g);
|
||||
const isActive = href === pathname || href === '/' + (subpath?.[0] || '');
|
||||
---
|
||||
|
||||
<a href={href} class:list={[className, { active: isActive }]} {...props}>
|
||||
<slot />
|
||||
</a>
|
||||
<style>
|
||||
a {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
a.active {
|
||||
font-weight: bolder;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +0,0 @@
|
||||
// Place any global data in this file.
|
||||
// You can import this data from anywhere in your site by using the `import` keyword.
|
||||
|
||||
export const SITE_TITLE = 'Nestri Blog';
|
||||
export const SITE_DESCRIPTION = 'Welcome to Nestri\'s Blog - This Blog is about the current status of and about intresting facts about Nestri';
|
||||
@@ -1,18 +0,0 @@
|
||||
import { glob } from 'astro/loaders';
|
||||
import { defineCollection, z } from 'astro:content';
|
||||
|
||||
const blog = defineCollection({
|
||||
// Load Markdown and MDX files in the `src/content/blog/` directory.
|
||||
loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' }),
|
||||
// Type-check frontmatter using a schema
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
// Transform string to Date object
|
||||
pubDate: z.coerce.date(),
|
||||
updatedDate: z.coerce.date().optional(),
|
||||
heroImage: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { blog };
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
title: 'First post'
|
||||
description: 'Lorem ipsum dolor sit amet'
|
||||
pubDate: 'Jul 08 2022'
|
||||
heroImage: '/blog-placeholder-3.jpg'
|
||||
---
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Vitae ultricies leo integer malesuada nunc vel risus commodo viverra. Adipiscing enim eu turpis egestas pretium. Euismod elementum nisi quis eleifend quam adipiscing. In hac habitasse platea dictumst vestibulum. Sagittis purus sit amet volutpat. Netus et malesuada fames ac turpis egestas. Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Varius sit amet mattis vulputate enim. Habitasse platea dictumst quisque sagittis. Integer quis auctor elit sed vulputate mi. Dictumst quisque sagittis purus sit amet.
|
||||
|
||||
Morbi tristique senectus et netus. Id semper risus in hendrerit gravida rutrum quisque non tellus. Habitasse platea dictumst quisque sagittis purus sit amet. Tellus molestie nunc non blandit massa. Cursus vitae congue mauris rhoncus. Accumsan tortor posuere ac ut. Fringilla urna porttitor rhoncus dolor. Elit ullamcorper dignissim cras tincidunt lobortis. In cursus turpis massa tincidunt dui ut ornare lectus. Integer feugiat scelerisque varius morbi enim nunc. Bibendum neque egestas congue quisque egestas diam. Cras ornare arcu dui vivamus arcu felis bibendum. Dignissim suspendisse in est ante in nibh mauris. Sed tempus urna et pharetra pharetra massa massa ultricies mi.
|
||||
|
||||
Mollis nunc sed id semper risus in. Convallis a cras semper auctor neque. Diam sit amet nisl suscipit. Lacus viverra vitae congue eu consequat ac felis donec. Egestas integer eget aliquet nibh praesent tristique magna sit amet. Eget magna fermentum iaculis eu non diam. In vitae turpis massa sed elementum. Tristique et egestas quis ipsum suspendisse ultrices. Eget lorem dolor sed viverra ipsum. Vel turpis nunc eget lorem dolor sed viverra. Posuere ac ut consequat semper viverra nam. Laoreet suspendisse interdum consectetur libero id faucibus. Diam phasellus vestibulum lorem sed risus ultricies tristique. Rhoncus dolor purus non enim praesent elementum facilisis. Ultrices tincidunt arcu non sodales neque. Tempus egestas sed sed risus pretium quam vulputate. Viverra suspendisse potenti nullam ac tortor vitae purus faucibus ornare. Fringilla urna porttitor rhoncus dolor purus non. Amet dictum sit amet justo donec enim.
|
||||
|
||||
Mattis ullamcorper velit sed ullamcorper morbi tincidunt. Tortor posuere ac ut consequat semper viverra. Tellus mauris a diam maecenas sed enim ut sem viverra. Venenatis urna cursus eget nunc scelerisque viverra mauris in. Arcu ac tortor dignissim convallis aenean et tortor at. Curabitur gravida arcu ac tortor dignissim convallis aenean et tortor. Egestas tellus rutrum tellus pellentesque eu. Fusce ut placerat orci nulla pellentesque dignissim enim sit amet. Ut enim blandit volutpat maecenas volutpat blandit aliquam etiam. Id donec ultrices tincidunt arcu. Id cursus metus aliquam eleifend mi.
|
||||
|
||||
Tempus quam pellentesque nec nam aliquam sem. Risus at ultrices mi tempus imperdiet. Id porta nibh venenatis cras sed felis eget velit. Ipsum a arcu cursus vitae. Facilisis magna etiam tempor orci eu lobortis elementum. Tincidunt dui ut ornare lectus sit. Quisque non tellus orci ac. Blandit libero volutpat sed cras. Nec tincidunt praesent semper feugiat nibh sed pulvinar proin gravida. Egestas integer eget aliquet nibh praesent tristique magna.
|
||||
@@ -1,65 +0,0 @@
|
||||
---
|
||||
title: 'Technical Deep Dive into Latency'
|
||||
description: "Why It's High and How to Reduce It"
|
||||
pubDate: 'May 18 2025'
|
||||
heroImage: '/pexels-brett-sayles-2881224.jpg'
|
||||
---
|
||||
|
||||
### Why It's High and How to Reduce It
|
||||
|
||||
First, let's start with the basics of the Internet.
|
||||
|
||||
The Internet connects clients and servers. Webpages primarily use the Application Layer protocol HTTP(S) to communicate with servers. HTTP is widely adopted for various applications, including mobile apps and other services requiring server communication.
|
||||
|
||||
There are also other client protocols like WebRTC (Web Real-Time Communication), which mainly powers streaming services needing a back channel. Nestri utilizes WebRTC, and we'll delve deeper into that later.
|
||||
|
||||
Imagine using a client protocol like WebRTC to send messages. Common formats for these messages include XML, HTML, or JSON.
|
||||
|
||||
While HTML contains significant duplicate symbols (e.g., `<a href="example.com">Some Link</a> <a href="example.com/subpage">Some nested Link</a>`), the modern web employs techniques to reduce its size. For instance, using modern zipping algorithms like gzip, this data can be compressed, resulting in a smaller size for transmission over the HTTP protocol.
|
||||
|
||||
In computer science, the more dense the information in a message (achieved through compression, for example), the higher its message entropy. Therefore, sending messages with high entropy is beneficial as it allows for the transfer of more information in a smaller package. Pure HTTP has relatively low entropy, similar to XML. JSON offers higher entropy, which can be further increased by removing whitespace and shortening attribute names. However, in modern client-server applications, JSON is often compressed.
|
||||
|
||||
So, we compress JSON traffic for efficiency. Have you ever compressed a large file? Modern systems make this process incredibly fast! But this requires computing power on both the client and server sides, which directly influences latency.
|
||||
|
||||
"Well, if I have a fiber connection, I don't need to worry about that..."
|
||||
|
||||
While a fiber connection offers significant bandwidth, this statement is somewhat misleading.
|
||||
|
||||
Latency also depends on your local network. A modern and stable Wi-Fi connection might seem sufficient, but the physical layer of the internet also contributes to latency. Wireless protocols, in particular, operate on a shared medium – the air. This medium is utilized by various devices, commonly on frequencies around 2.4 or 5 GHz. This spectrum is divided among all these devices. Mechanisms like scheduling and signal modulation are used to manage this shared resource. In essence, to avoid a deeper dive into wireless communication, a wired connection is generally superior to a wireless connection due to the absence of a shared physical medium.
|
||||
|
||||
Okay, but what about Ethernet or fiber cables? Aren't we sharing those as well, with multiple applications or other internet users?
|
||||
|
||||
Yes, this also impacts latency. If many users in your local area are utilizing the same uplinks to a backbone (a high-speed part of the internet), you'll have to share that bandwidth. While fiber optic cables have substantial capacity due to advanced modulation techniques, consider the journey these data packets undertake across the internet.
|
||||
|
||||
Sometimes, if a data center is located nearby, your connection will involve fewer routers (fewer hops) between you and the server. Fewer hops generally translate to lower latency. Each router needs to queue your messages and determine the next destination. Modern routing protocols facilitate this process. However, even routers have to process messages in their queues. Thus, higher message entropy means fewer or smaller packets need to be sent.
|
||||
|
||||
What happens when your messages are too large for transmission? They are split into multiple parts and sent using protocols like TCP. TCP ensures reliable packet exchange by retransmitting any packets that are likely lost during internet transit. Packet loss can occur if a router's queue overflows, forcing it to drop packets, potentially prioritizing other traffic. This retransmission significantly increases latency as a packet might need to be sent multiple times.
|
||||
|
||||
UDP offers a different approach: it sends all packets without the overhead of retransmission. In this case, the application protocol is responsible for handling any lost packets. Fortunately, there's an application protocol that manages this quite effectively: WebRTC.
|
||||
|
||||
WebRTC is an open-source project providing APIs for real-time communication of audio, video, and generic data between peers via a browser. It leverages protocols like ICE, STUN, and TURN to handle NAT traversal and establish peer-to-peer connections, enabling low-latency media streaming and data exchange directly within web applications.
|
||||
|
||||
Sending raw video streams over WebRTC is inefficient; they require compression using modern codecs. A GPU is the optimal choice for this task because it has dedicated hardware (hardware encoder) to accelerate video encoding, significantly speeding up the process compared to software encoding on a CPU. Therefore, your GPU also plays a crucial role in reducing latency during video encoding and decoding.
|
||||
|
||||
So, why is all this relevant to Nestri?
|
||||
|
||||
We aim to deliver a cutting-edge, low-latency cloud gaming experience. Here's what we've implemented to combat bad latency:
|
||||
|
||||
**1. Reducing Mouse and Keyboard Latency**
|
||||
1. Reduce package size by using the Protobuf protocol instead of JSON.
|
||||
2. Avoid wasting compute power by not compressing these already optimized messages.
|
||||
3. Minimize message flooding by bundling multiple mouse events into fewer messages through aggregation.
|
||||
4. Implement all of this within WebRTC for a super lightweight communication over UDP.
|
||||
|
||||
**2. Reducing Video Latency**
|
||||
1. Utilize cutting-edge encoder-decoders on a GPU instead of a CPU.
|
||||
|
||||
**3. Reducing Network Latency in the Backbone**
|
||||
1. Bring servers closer to users to reduce the hop count.
|
||||
|
||||
Here's a glimpse of the results of these improvements, comparing the experience before and after implementation:
|
||||
|
||||
](https://fs.dathorse.com/w/ad2bee7e322b942491044fcffcccc899)
|
||||
**Latency Test and comparison to the old Nestri**
|
||||
|
||||
Did you enjoy this blog post? Join our Discord and share your thoughts!
|
||||
@@ -1,214 +0,0 @@
|
||||
---
|
||||
title: 'Markdown Style Guide'
|
||||
description: 'Here is a sample of some basic Markdown syntax that can be used when writing Markdown content in Astro.'
|
||||
pubDate: 'Jun 19 2024'
|
||||
heroImage: '/blog-placeholder-1.jpg'
|
||||
---
|
||||
|
||||
Here is a sample of some basic Markdown syntax that can be used when writing Markdown content in Astro.
|
||||
|
||||
## Headings
|
||||
|
||||
The following HTML `<h1>`—`<h6>` elements represent six levels of section headings. `<h1>` is the highest section level while `<h6>` is the lowest.
|
||||
|
||||
# H1
|
||||
|
||||
## H2
|
||||
|
||||
### H3
|
||||
|
||||
#### H4
|
||||
|
||||
##### H5
|
||||
|
||||
###### H6
|
||||
|
||||
## Paragraph
|
||||
|
||||
Xerum, quo qui aut unt expliquam qui dolut labo. Aque venitatiusda cum, voluptionse latur sitiae dolessi aut parist aut dollo enim qui voluptate ma dolestendit peritin re plis aut quas inctum laceat est volestemque commosa as cus endigna tectur, offic to cor sequas etum rerum idem sintibus eiur? Quianimin porecus evelectur, cum que nis nust voloribus ratem aut omnimi, sitatur? Quiatem. Nam, omnis sum am facea corem alique molestrunt et eos evelece arcillit ut aut eos eos nus, sin conecerem erum fuga. Ri oditatquam, ad quibus unda veliamenimin cusam et facea ipsamus es exerum sitate dolores editium rerore eost, temped molorro ratiae volorro te reribus dolorer sperchicium faceata tiustia prat.
|
||||
|
||||
Itatur? Quiatae cullecum rem ent aut odis in re eossequodi nonsequ idebis ne sapicia is sinveli squiatum, core et que aut hariosam ex eat.
|
||||
|
||||
## Images
|
||||
|
||||
### Syntax
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||

|
||||
|
||||
## Blockquotes
|
||||
|
||||
The blockquote element represents content that is quoted from another source, optionally with a citation which must be within a `footer` or `cite` element, and optionally with in-line changes such as annotations and abbreviations.
|
||||
|
||||
### Blockquote without attribution
|
||||
|
||||
#### Syntax
|
||||
|
||||
```markdown
|
||||
> Tiam, ad mint andaepu dandae nostion secatur sequo quae.
|
||||
> **Note** that you can use _Markdown syntax_ within a blockquote.
|
||||
```
|
||||
|
||||
#### Output
|
||||
|
||||
> Tiam, ad mint andaepu dandae nostion secatur sequo quae.
|
||||
> **Note** that you can use _Markdown syntax_ within a blockquote.
|
||||
|
||||
### Blockquote with attribution
|
||||
|
||||
#### Syntax
|
||||
|
||||
```markdown
|
||||
> Don't communicate by sharing memory, share memory by communicating.<br>
|
||||
> — <cite>Rob Pike[^1]</cite>
|
||||
```
|
||||
|
||||
#### Output
|
||||
|
||||
> Don't communicate by sharing memory, share memory by communicating.<br>
|
||||
> — <cite>Rob Pike[^1]</cite>
|
||||
|
||||
[^1]: The above quote is excerpted from Rob Pike's [talk](https://www.youtube.com/watch?v=PAAkCSZUG1c) during Gopherfest, November 18, 2015.
|
||||
|
||||
## Tables
|
||||
|
||||
### Syntax
|
||||
|
||||
```markdown
|
||||
| Italics | Bold | Code |
|
||||
| --------- | -------- | ------ |
|
||||
| _italics_ | **bold** | `code` |
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
| Italics | Bold | Code |
|
||||
| --------- | -------- | ------ |
|
||||
| _italics_ | **bold** | `code` |
|
||||
|
||||
## Code Blocks
|
||||
|
||||
### Syntax
|
||||
|
||||
we can use 3 backticks ``` in new line and write snippet and close with 3 backticks on new line and to highlight language specific syntax, write one word of language name after first 3 backticks, for eg. html, javascript, css, markdown, typescript, txt, bash
|
||||
|
||||
````markdown
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Example HTML5 Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Test</p>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
````
|
||||
|
||||
### Output
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Example HTML5 Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Test</p>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## List Types
|
||||
|
||||
### Ordered List
|
||||
|
||||
#### Syntax
|
||||
|
||||
```markdown
|
||||
1. First item
|
||||
2. Second item
|
||||
3. Third item
|
||||
```
|
||||
|
||||
#### Output
|
||||
|
||||
1. First item
|
||||
2. Second item
|
||||
3. Third item
|
||||
|
||||
### Unordered List
|
||||
|
||||
#### Syntax
|
||||
|
||||
```markdown
|
||||
- List item
|
||||
- Another item
|
||||
- And another item
|
||||
```
|
||||
|
||||
#### Output
|
||||
|
||||
- List item
|
||||
- Another item
|
||||
- And another item
|
||||
|
||||
### Nested list
|
||||
|
||||
#### Syntax
|
||||
|
||||
```markdown
|
||||
- Fruit
|
||||
- Apple
|
||||
- Orange
|
||||
- Banana
|
||||
- Dairy
|
||||
- Milk
|
||||
- Cheese
|
||||
```
|
||||
|
||||
#### Output
|
||||
|
||||
- Fruit
|
||||
- Apple
|
||||
- Orange
|
||||
- Banana
|
||||
- Dairy
|
||||
- Milk
|
||||
- Cheese
|
||||
|
||||
## Other Elements — abbr, sub, sup, kbd, mark
|
||||
|
||||
### Syntax
|
||||
|
||||
```markdown
|
||||
<abbr title="Graphics Interchange Format">GIF</abbr> is a bitmap image format.
|
||||
|
||||
H<sub>2</sub>O
|
||||
|
||||
X<sup>n</sup> + Y<sup>n</sup> = Z<sup>n</sup>
|
||||
|
||||
Press <kbd>CTRL</kbd> + <kbd>ALT</kbd> + <kbd>Delete</kbd> to end the session.
|
||||
|
||||
Most <mark>salamanders</mark> are nocturnal, and hunt for insects, worms, and other small creatures.
|
||||
```
|
||||
|
||||
### Output
|
||||
|
||||
<abbr title="Graphics Interchange Format">GIF</abbr> is a bitmap image format.
|
||||
|
||||
H<sub>2</sub>O
|
||||
|
||||
X<sup>n</sup> + Y<sup>n</sup> = Z<sup>n</sup>
|
||||
|
||||
Press <kbd>CTRL</kbd> + <kbd>ALT</kbd> + <kbd>Delete</kbd> to end the session.
|
||||
|
||||
Most <mark>salamanders</mark> are nocturnal, and hunt for insects, worms, and other small creatures.
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
title: 'Second post'
|
||||
description: 'Lorem ipsum dolor sit amet'
|
||||
pubDate: 'Jul 15 2022'
|
||||
heroImage: '/blog-placeholder-4.jpg'
|
||||
---
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Vitae ultricies leo integer malesuada nunc vel risus commodo viverra. Adipiscing enim eu turpis egestas pretium. Euismod elementum nisi quis eleifend quam adipiscing. In hac habitasse platea dictumst vestibulum. Sagittis purus sit amet volutpat. Netus et malesuada fames ac turpis egestas. Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Varius sit amet mattis vulputate enim. Habitasse platea dictumst quisque sagittis. Integer quis auctor elit sed vulputate mi. Dictumst quisque sagittis purus sit amet.
|
||||
|
||||
Morbi tristique senectus et netus. Id semper risus in hendrerit gravida rutrum quisque non tellus. Habitasse platea dictumst quisque sagittis purus sit amet. Tellus molestie nunc non blandit massa. Cursus vitae congue mauris rhoncus. Accumsan tortor posuere ac ut. Fringilla urna porttitor rhoncus dolor. Elit ullamcorper dignissim cras tincidunt lobortis. In cursus turpis massa tincidunt dui ut ornare lectus. Integer feugiat scelerisque varius morbi enim nunc. Bibendum neque egestas congue quisque egestas diam. Cras ornare arcu dui vivamus arcu felis bibendum. Dignissim suspendisse in est ante in nibh mauris. Sed tempus urna et pharetra pharetra massa massa ultricies mi.
|
||||
|
||||
Mollis nunc sed id semper risus in. Convallis a cras semper auctor neque. Diam sit amet nisl suscipit. Lacus viverra vitae congue eu consequat ac felis donec. Egestas integer eget aliquet nibh praesent tristique magna sit amet. Eget magna fermentum iaculis eu non diam. In vitae turpis massa sed elementum. Tristique et egestas quis ipsum suspendisse ultrices. Eget lorem dolor sed viverra ipsum. Vel turpis nunc eget lorem dolor sed viverra. Posuere ac ut consequat semper viverra nam. Laoreet suspendisse interdum consectetur libero id faucibus. Diam phasellus vestibulum lorem sed risus ultricies tristique. Rhoncus dolor purus non enim praesent elementum facilisis. Ultrices tincidunt arcu non sodales neque. Tempus egestas sed sed risus pretium quam vulputate. Viverra suspendisse potenti nullam ac tortor vitae purus faucibus ornare. Fringilla urna porttitor rhoncus dolor purus non. Amet dictum sit amet justo donec enim.
|
||||
|
||||
Mattis ullamcorper velit sed ullamcorper morbi tincidunt. Tortor posuere ac ut consequat semper viverra. Tellus mauris a diam maecenas sed enim ut sem viverra. Venenatis urna cursus eget nunc scelerisque viverra mauris in. Arcu ac tortor dignissim convallis aenean et tortor at. Curabitur gravida arcu ac tortor dignissim convallis aenean et tortor. Egestas tellus rutrum tellus pellentesque eu. Fusce ut placerat orci nulla pellentesque dignissim enim sit amet. Ut enim blandit volutpat maecenas volutpat blandit aliquam etiam. Id donec ultrices tincidunt arcu. Id cursus metus aliquam eleifend mi.
|
||||
|
||||
Tempus quam pellentesque nec nam aliquam sem. Risus at ultrices mi tempus imperdiet. Id porta nibh venenatis cras sed felis eget velit. Ipsum a arcu cursus vitae. Facilisis magna etiam tempor orci eu lobortis elementum. Tincidunt dui ut ornare lectus sit. Quisque non tellus orci ac. Blandit libero volutpat sed cras. Nec tincidunt praesent semper feugiat nibh sed pulvinar proin gravida. Egestas integer eget aliquet nibh praesent tristique magna.
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
title: 'Third post'
|
||||
description: 'Lorem ipsum dolor sit amet'
|
||||
pubDate: 'Jul 22 2022'
|
||||
heroImage: '/blog-placeholder-2.jpg'
|
||||
---
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Vitae ultricies leo integer malesuada nunc vel risus commodo viverra. Adipiscing enim eu turpis egestas pretium. Euismod elementum nisi quis eleifend quam adipiscing. In hac habitasse platea dictumst vestibulum. Sagittis purus sit amet volutpat. Netus et malesuada fames ac turpis egestas. Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Varius sit amet mattis vulputate enim. Habitasse platea dictumst quisque sagittis. Integer quis auctor elit sed vulputate mi. Dictumst quisque sagittis purus sit amet.
|
||||
|
||||
Morbi tristique senectus et netus. Id semper risus in hendrerit gravida rutrum quisque non tellus. Habitasse platea dictumst quisque sagittis purus sit amet. Tellus molestie nunc non blandit massa. Cursus vitae congue mauris rhoncus. Accumsan tortor posuere ac ut. Fringilla urna porttitor rhoncus dolor. Elit ullamcorper dignissim cras tincidunt lobortis. In cursus turpis massa tincidunt dui ut ornare lectus. Integer feugiat scelerisque varius morbi enim nunc. Bibendum neque egestas congue quisque egestas diam. Cras ornare arcu dui vivamus arcu felis bibendum. Dignissim suspendisse in est ante in nibh mauris. Sed tempus urna et pharetra pharetra massa massa ultricies mi.
|
||||
|
||||
Mollis nunc sed id semper risus in. Convallis a cras semper auctor neque. Diam sit amet nisl suscipit. Lacus viverra vitae congue eu consequat ac felis donec. Egestas integer eget aliquet nibh praesent tristique magna sit amet. Eget magna fermentum iaculis eu non diam. In vitae turpis massa sed elementum. Tristique et egestas quis ipsum suspendisse ultrices. Eget lorem dolor sed viverra ipsum. Vel turpis nunc eget lorem dolor sed viverra. Posuere ac ut consequat semper viverra nam. Laoreet suspendisse interdum consectetur libero id faucibus. Diam phasellus vestibulum lorem sed risus ultricies tristique. Rhoncus dolor purus non enim praesent elementum facilisis. Ultrices tincidunt arcu non sodales neque. Tempus egestas sed sed risus pretium quam vulputate. Viverra suspendisse potenti nullam ac tortor vitae purus faucibus ornare. Fringilla urna porttitor rhoncus dolor purus non. Amet dictum sit amet justo donec enim.
|
||||
|
||||
Mattis ullamcorper velit sed ullamcorper morbi tincidunt. Tortor posuere ac ut consequat semper viverra. Tellus mauris a diam maecenas sed enim ut sem viverra. Venenatis urna cursus eget nunc scelerisque viverra mauris in. Arcu ac tortor dignissim convallis aenean et tortor at. Curabitur gravida arcu ac tortor dignissim convallis aenean et tortor. Egestas tellus rutrum tellus pellentesque eu. Fusce ut placerat orci nulla pellentesque dignissim enim sit amet. Ut enim blandit volutpat maecenas volutpat blandit aliquam etiam. Id donec ultrices tincidunt arcu. Id cursus metus aliquam eleifend mi.
|
||||
|
||||
Tempus quam pellentesque nec nam aliquam sem. Risus at ultrices mi tempus imperdiet. Id porta nibh venenatis cras sed felis eget velit. Ipsum a arcu cursus vitae. Facilisis magna etiam tempor orci eu lobortis elementum. Tincidunt dui ut ornare lectus sit. Quisque non tellus orci ac. Blandit libero volutpat sed cras. Nec tincidunt praesent semper feugiat nibh sed pulvinar proin gravida. Egestas integer eget aliquet nibh praesent tristique magna.
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
title: 'Using MDX'
|
||||
description: 'Lorem ipsum dolor sit amet'
|
||||
pubDate: 'Jun 01 2024'
|
||||
heroImage: '/blog-placeholder-5.jpg'
|
||||
---
|
||||
|
||||
This theme comes with the [@astrojs/mdx](https://docs.astro.build/en/guides/integrations-guide/mdx/) integration installed and configured in your `astro.config.mjs` config file. If you prefer not to use MDX, you can disable support by removing the integration from your config file.
|
||||
|
||||
## Why MDX?
|
||||
|
||||
MDX is a special flavor of Markdown that supports embedded JavaScript & JSX syntax. This unlocks the ability to [mix JavaScript and UI Components into your Markdown content](https://docs.astro.build/en/guides/markdown-content/#mdx-features) for things like interactive charts or alerts.
|
||||
|
||||
If you have existing content authored in MDX, this integration will hopefully make migrating to Astro a breeze.
|
||||
|
||||
## Example
|
||||
|
||||
Here is how you import and use a UI component inside of MDX.
|
||||
When you open this page in the browser, you should see the clickable button below.
|
||||
|
||||
import HeaderLink from '../../components/HeaderLink.astro';
|
||||
|
||||
<HeaderLink href="#" onclick="alert('clicked!')">
|
||||
Embedded component in MDX
|
||||
</HeaderLink>
|
||||
|
||||
## More Links
|
||||
|
||||
- [MDX Syntax Documentation](https://mdxjs.com/docs/what-is-mdx)
|
||||
- [Astro Usage Documentation](https://docs.astro.build/en/guides/markdown-content/#markdown-and-mdx-pages)
|
||||
- **Note:** [Client Directives](https://docs.astro.build/en/reference/directives-reference/#client-directives) are still required to create interactive components. Otherwise, all components in your MDX will render as static HTML (no JavaScript) by default.
|
||||
@@ -1,92 +0,0 @@
|
||||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import BaseHead from '../components/BaseHead.astro';
|
||||
import Header from '../components/Header.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import FormattedDate from '../components/FormattedDate.astro';
|
||||
import "../styles/global.css"
|
||||
|
||||
type Props = CollectionEntry<'blog'>['data'];
|
||||
|
||||
const { title, description, pubDate, updatedDate, heroImage } = Astro.props;
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<BaseHead title={title} description={description} />
|
||||
<style>
|
||||
main {
|
||||
width: calc(100% - 2em);
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.hero-image {
|
||||
width: 100%;
|
||||
}
|
||||
.hero-image img {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.prose {
|
||||
width: 720px;
|
||||
max-width: calc(100% - 2em);
|
||||
margin: auto;
|
||||
padding: 1em;
|
||||
color: rgb(var(--gray-dark));
|
||||
}
|
||||
.title {
|
||||
margin-bottom: 1em;
|
||||
padding: 1em 0;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
}
|
||||
.title h1 {
|
||||
margin: 0 0 0.5em 0;
|
||||
}
|
||||
.date {
|
||||
margin-bottom: 0.5em;
|
||||
color: rgb(var(--gray));
|
||||
}
|
||||
.last-updated-on {
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Header />
|
||||
<main>
|
||||
<article>
|
||||
|
||||
<div class="grid gap-8 items-start justify-center">
|
||||
<div class="relative group">
|
||||
<div class="absolute -inset-0.5 bg-radial-gradient opacity-40 group-hover:opacity-80 transition duration-1000 group-hover:duration-200 animate-tilt" />
|
||||
<div class="relative bg-black rounded-lg leading-none flex items-center divide-x divide-gray-600">
|
||||
{heroImage && <img width={1020} height={510} src={heroImage} alt="" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prose">
|
||||
<div class="title">
|
||||
<div class="date">
|
||||
<FormattedDate date={pubDate} />
|
||||
{
|
||||
updatedDate && (
|
||||
<div class="last-updated-on">
|
||||
Last updated on <FormattedDate date={updatedDate} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<h1>{title}</h1>
|
||||
<hr />
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,62 +0,0 @@
|
||||
---
|
||||
import Layout from '../layouts/BlogPost.astro';
|
||||
---
|
||||
|
||||
<Layout
|
||||
title="About Me"
|
||||
description="Lorem ipsum dolor sit amet"
|
||||
pubDate={new Date('August 08 2021')}
|
||||
heroImage="/blog-placeholder-about.jpg"
|
||||
>
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut
|
||||
labore et dolore magna aliqua. Vitae ultricies leo integer malesuada nunc vel risus commodo
|
||||
viverra. Adipiscing enim eu turpis egestas pretium. Euismod elementum nisi quis eleifend quam
|
||||
adipiscing. In hac habitasse platea dictumst vestibulum. Sagittis purus sit amet volutpat. Netus
|
||||
et malesuada fames ac turpis egestas. Eget magna fermentum iaculis eu non diam phasellus
|
||||
vestibulum lorem. Varius sit amet mattis vulputate enim. Habitasse platea dictumst quisque
|
||||
sagittis. Integer quis auctor elit sed vulputate mi. Dictumst quisque sagittis purus sit amet.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Morbi tristique senectus et netus. Id semper risus in hendrerit gravida rutrum quisque non
|
||||
tellus. Habitasse platea dictumst quisque sagittis purus sit amet. Tellus molestie nunc non
|
||||
blandit massa. Cursus vitae congue mauris rhoncus. Accumsan tortor posuere ac ut. Fringilla urna
|
||||
porttitor rhoncus dolor. Elit ullamcorper dignissim cras tincidunt lobortis. In cursus turpis
|
||||
massa tincidunt dui ut ornare lectus. Integer feugiat scelerisque varius morbi enim nunc.
|
||||
Bibendum neque egestas congue quisque egestas diam. Cras ornare arcu dui vivamus arcu felis
|
||||
bibendum. Dignissim suspendisse in est ante in nibh mauris. Sed tempus urna et pharetra pharetra
|
||||
massa massa ultricies mi.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Mollis nunc sed id semper risus in. Convallis a cras semper auctor neque. Diam sit amet nisl
|
||||
suscipit. Lacus viverra vitae congue eu consequat ac felis donec. Egestas integer eget aliquet
|
||||
nibh praesent tristique magna sit amet. Eget magna fermentum iaculis eu non diam. In vitae
|
||||
turpis massa sed elementum. Tristique et egestas quis ipsum suspendisse ultrices. Eget lorem
|
||||
dolor sed viverra ipsum. Vel turpis nunc eget lorem dolor sed viverra. Posuere ac ut consequat
|
||||
semper viverra nam. Laoreet suspendisse interdum consectetur libero id faucibus. Diam phasellus
|
||||
vestibulum lorem sed risus ultricies tristique. Rhoncus dolor purus non enim praesent elementum
|
||||
facilisis. Ultrices tincidunt arcu non sodales neque. Tempus egestas sed sed risus pretium quam
|
||||
vulputate. Viverra suspendisse potenti nullam ac tortor vitae purus faucibus ornare. Fringilla
|
||||
urna porttitor rhoncus dolor purus non. Amet dictum sit amet justo donec enim.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Mattis ullamcorper velit sed ullamcorper morbi tincidunt. Tortor posuere ac ut consequat semper
|
||||
viverra. Tellus mauris a diam maecenas sed enim ut sem viverra. Venenatis urna cursus eget nunc
|
||||
scelerisque viverra mauris in. Arcu ac tortor dignissim convallis aenean et tortor at. Curabitur
|
||||
gravida arcu ac tortor dignissim convallis aenean et tortor. Egestas tellus rutrum tellus
|
||||
pellentesque eu. Fusce ut placerat orci nulla pellentesque dignissim enim sit amet. Ut enim
|
||||
blandit volutpat maecenas volutpat blandit aliquam etiam. Id donec ultrices tincidunt arcu. Id
|
||||
cursus metus aliquam eleifend mi.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Tempus quam pellentesque nec nam aliquam sem. Risus at ultrices mi tempus imperdiet. Id porta
|
||||
nibh venenatis cras sed felis eget velit. Ipsum a arcu cursus vitae. Facilisis magna etiam
|
||||
tempor orci eu lobortis elementum. Tincidunt dui ut ornare lectus sit. Quisque non tellus orci
|
||||
ac. Blandit libero volutpat sed cras. Nec tincidunt praesent semper feugiat nibh sed pulvinar
|
||||
proin gravida. Egestas integer eget aliquet nibh praesent tristique magna.
|
||||
</p>
|
||||
</Layout>
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
import { type CollectionEntry, getCollection } from 'astro:content';
|
||||
import BlogPost from '../../layouts/BlogPost.astro';
|
||||
import { render } from 'astro:content';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('blog');
|
||||
return posts.map((post) => ({
|
||||
params: { slug: post.id },
|
||||
props: post,
|
||||
}));
|
||||
}
|
||||
type Props = CollectionEntry<'blog'>;
|
||||
|
||||
const post = Astro.props;
|
||||
const { Content } = await render(post);
|
||||
---
|
||||
|
||||
<BlogPost {...post.data}>
|
||||
<Content />
|
||||
</BlogPost>
|
||||
@@ -1,120 +0,0 @@
|
||||
---
|
||||
import BaseHead from '../../components/BaseHead.astro';
|
||||
import Header from '../../components/Header.astro';
|
||||
import Footer from '../../components/Footer.astro';
|
||||
import { SITE_TITLE, SITE_DESCRIPTION } from '../../consts';
|
||||
import { getCollection } from 'astro:content';
|
||||
import FormattedDate from '../../components/FormattedDate.astro';
|
||||
import "../../styles/global.css"
|
||||
|
||||
const posts = (await getCollection('blog')).sort(
|
||||
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
|
||||
);
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<BaseHead title={SITE_TITLE} description={SITE_DESCRIPTION} />
|
||||
<style>
|
||||
main {
|
||||
width: 960px;
|
||||
}
|
||||
ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2rem;
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
ul li {
|
||||
width: calc(50% - 1rem);
|
||||
}
|
||||
ul li * {
|
||||
text-decoration: none;
|
||||
transition: 0.2s ease;
|
||||
}
|
||||
ul li:first-child {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
ul li:first-child img {
|
||||
width: 100%;
|
||||
}
|
||||
ul li:first-child .title {
|
||||
font-size: 2.369rem;
|
||||
}
|
||||
ul li img {
|
||||
|
||||
}
|
||||
ul li a {
|
||||
display: block;
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
color: #d9d9d9;
|
||||
line-height: 1;
|
||||
}
|
||||
.date {
|
||||
margin: 0;
|
||||
color: #c0c0c0;
|
||||
}
|
||||
ul li a:hover h4,
|
||||
ul li a:hover .date {
|
||||
color: #f2f2f2;
|
||||
}
|
||||
ul a:hover img {
|
||||
box-shadow: var(--box-shadow);
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
ul {
|
||||
gap: 0.5em;
|
||||
}
|
||||
ul li {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
ul li:first-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
ul li:first-child .title {
|
||||
font-size: 1.563em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<Header />
|
||||
<main>
|
||||
<section>
|
||||
<ul>
|
||||
{
|
||||
posts.map((post) => (
|
||||
<li>
|
||||
<a href={`/blog/${post.id}/`}>
|
||||
|
||||
<div class="grid gap-8 items-start justify-center">
|
||||
<div class="relative group">
|
||||
<div class="absolute -inset-0.5 bg-radial-gradient opacity-0 group-hover:opacity-80 transition duration-1000 group-hover:duration-200 animate-tilt" />
|
||||
<div class="relative bg-black rounded-lg leading-none flex items-center divide-x divide-gray-600">
|
||||
<img width={720} height={360} src={post.data.heroImage} alt="" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="title py-4">{post.data.title}</h4>
|
||||
<p class="date">
|
||||
<FormattedDate date={post.data.pubDate} />
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
import BaseHead from '../components/BaseHead.astro';
|
||||
import Header from '../components/Header.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import { SITE_TITLE, SITE_DESCRIPTION } from '../consts';
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<BaseHead title={SITE_TITLE} description={SITE_DESCRIPTION} />
|
||||
</head>
|
||||
<body>
|
||||
<Header />
|
||||
<main>
|
||||
<h1>🧑🚀 Hello, Astronaut!</h1>
|
||||
<p>
|
||||
Welcome to the official <a href="https://astro.build/">Astro</a> blog starter template. This
|
||||
template serves as a lightweight, minimally-styled starting point for anyone looking to build
|
||||
a personal website, blog, or portfolio with Astro.
|
||||
</p>
|
||||
<p>
|
||||
This template comes with a few integrations already configured in your
|
||||
<code>astro.config.mjs</code> file. You can customize your setup with
|
||||
<a href="https://astro.build/integrations">Astro Integrations</a> to add tools like Tailwind,
|
||||
React, or Vue to your project.
|
||||
</p>
|
||||
<p>Here are a few ideas on how to get started with the template:</p>
|
||||
<ul>
|
||||
<li>Edit this page in <code>src/pages/index.astro</code></li>
|
||||
<li>Edit the site header items in <code>src/components/Header.astro</code></li>
|
||||
<li>Add your name to the footer in <code>src/components/Footer.astro</code></li>
|
||||
<li>Check out the included blog posts in <code>src/content/blog/</code></li>
|
||||
<li>Customize the blog post page layout in <code>src/layouts/BlogPost.astro</code></li>
|
||||
</ul>
|
||||
<p>
|
||||
Have fun! If you get stuck, remember to
|
||||
<a href="https://docs.astro.build/">read the docs</a>
|
||||
or <a href="https://astro.build/chat">join us on Discord</a> to ask questions.
|
||||
</p>
|
||||
<p>
|
||||
Looking for a blog template with a bit more personality? Check out
|
||||
<a href="https://github.com/Charca/astro-blog-template">astro-blog-template</a>
|
||||
by <a href="https://twitter.com/Charca">Maxi Ferreira</a>.
|
||||
</p>
|
||||
</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,16 +0,0 @@
|
||||
import rss from '@astrojs/rss';
|
||||
import { getCollection } from 'astro:content';
|
||||
import { SITE_TITLE, SITE_DESCRIPTION } from '../consts';
|
||||
|
||||
export async function GET(context) {
|
||||
const posts = await getCollection('blog');
|
||||
return rss({
|
||||
title: SITE_TITLE,
|
||||
description: SITE_DESCRIPTION,
|
||||
site: context.site,
|
||||
items: posts.map((post) => ({
|
||||
...post.data,
|
||||
link: `/blog/${post.id}/`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
/*
|
||||
The CSS in this style tag is based off of Bear Blog's default CSS.
|
||||
https://github.com/HermanMartinus/bearblog/blob/297026a877bc2ab2b3bdfbd6b9f7961c350917dd/templates/styles/blog/default.css
|
||||
License MIT: https://github.com/HermanMartinus/bearblog/blob/master/LICENSE.md
|
||||
*/
|
||||
@import "tailwindcss";
|
||||
|
||||
|
||||
:root {
|
||||
/*--accent: rgb(255, 79, 1);*/
|
||||
/*--accent-dark: #fafafa;*/
|
||||
/*--black: 15, 18, 25;*/
|
||||
/*--gray: 96, 1, 159;*/
|
||||
/*--gray-light: 82, 82, 82;*/
|
||||
--gray-dark: 250, 250, 250;
|
||||
--gray-gradient: rgba(var(--gray-light), 50%), #fff;
|
||||
--box-shadow:
|
||||
0 2px 6px rgba(var(--gray), 25%), 0 8px 24px rgba(var(--gray), 33%),
|
||||
0 16px 32px rgba(var(--gray), 33%);
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Atkinson';
|
||||
src: url('/fonts/atkinson-regular.woff') format('woff');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Atkinson';
|
||||
src: url('/fonts/atkinson-bold.woff') format('woff');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Atkinson', sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
background: linear-gradient(var(--gray-gradient)) no-repeat;
|
||||
background-color: #171717;
|
||||
background-size: 100% 600px;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
color: rgb(var(--gray-dark));
|
||||
font-size: 20px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
main {
|
||||
width: 720px;
|
||||
max-width: calc(100% - 2em);
|
||||
margin: auto;
|
||||
padding: 3em 1em;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: rgb(var(--black));
|
||||
line-height: 1.2;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3.052em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 2.441em;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.953em;
|
||||
}
|
||||
h4 {
|
||||
font-size: 1.563em;
|
||||
}
|
||||
h5 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
strong,
|
||||
b {
|
||||
font-weight: 700;
|
||||
}
|
||||
a {
|
||||
color: var(--accent);
|
||||
}
|
||||
a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.prose p {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
textarea {
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
}
|
||||
input {
|
||||
font-size: 16px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
code {
|
||||
padding: 2px 5px;
|
||||
background-color: rgb(var(--gray-light));
|
||||
border-radius: 2px;
|
||||
}
|
||||
pre {
|
||||
padding: 1.5em;
|
||||
border-radius: 8px;
|
||||
}
|
||||
pre > code {
|
||||
all: unset;
|
||||
}
|
||||
blockquote {
|
||||
border-left: 4px solid var(--accent);
|
||||
padding: 0 0 0 20px;
|
||||
margin: 0px;
|
||||
font-size: 1.333em;
|
||||
}
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid rgb(var(--gray-light));
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
body {
|
||||
font-size: 18px;
|
||||
}
|
||||
main {
|
||||
padding: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: absolute !important;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
/* IE6, IE7 - a 0 height clip, off to the bottom right of the visible 1px box */
|
||||
clip: rect(1px 1px 1px 1px);
|
||||
/* maybe deprecated but we need to support legacy browsers */
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
/* modern browsers, clip-path works inwards from each corner */
|
||||
clip-path: inset(50%);
|
||||
/* added line to stop words getting smushed together (as they go onto separate lines and some screen readers do not understand line feeds as a space */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bg-radial-gradient {
|
||||
filter: blur(32px);
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
rgb(239, 118, 70),
|
||||
rgb(251, 91, 88),
|
||||
rgb(255, 61, 116),
|
||||
rgb(249, 33, 149),
|
||||
rgb(227, 34, 188),
|
||||
rgb(181, 94, 230),
|
||||
rgb(118, 128, 252),
|
||||
rgb(0, 150, 255),
|
||||
rgb(0, 183, 255),
|
||||
rgb(0, 208, 242),
|
||||
rgb(0, 227, 184),
|
||||
rgb(70, 239, 111)
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [
|
||||
".astro/types.d.ts",
|
||||
"**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"dist"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"strictNullChecks": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js"
|
||||
}
|
||||
}
|
||||
@@ -116,8 +116,30 @@ export default component$(() => {
|
||||
|
||||
return (
|
||||
<div class="w-screen relative">
|
||||
<TitleSection client:load title="Pricing" description={"We're growing at the speed of trust. Choose a price that feels right for you and help support Nestri"} />
|
||||
<MotionComponent
|
||||
<TitleSection client:load title="Pricing" description={"The biggest bang, binge, and blast for your buck"} />
|
||||
|
||||
<Footer client:load>
|
||||
<div class="w-full flex justify-center flex-col items-center gap-3">
|
||||
<Link href="https://discord.gg/6um5K6jrYj" prefetch={false} class="flex font-bricolage text-sm sm:text-base rounded-full bg-primary-500 px-5 py-4 font-semibold text-white transition-all hover:scale-105 active:scale-95 sm:px-6" >
|
||||
Join our Discord
|
||||
</Link>
|
||||
<div class="mt-6 flex w-full items-center justify-center gap-2 text-xs sm:text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
<span class="hover:text-primary-500 transition-colors duration-200">
|
||||
<Link rel="noreferrer" href="/terms" >Terms of Service</Link></span>
|
||||
<span class="text-gray-400 dark:text-gray-600">•</span>
|
||||
<span class="hover:text-primary-500 transition-colors duration-200" >
|
||||
<Link href="/privacy">Privacy Policy</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Footer>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* <MotionComponent
|
||||
initial={{ opacity: 0, y: 100 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
@@ -144,7 +166,6 @@ export default component$(() => {
|
||||
</div>
|
||||
<div class="flex flex-col w-full">
|
||||
<p class="text-[4rem] leading-[1] font-medium font-title"> Free </p>
|
||||
{/**FIXME: Add the link to the docs here */}
|
||||
<a href={CONSTANTS.githubLink} ref={v => bookRef.value = v} class="h-[154px] w-full flex items-start pt-4 justify-center overflow-hidden">
|
||||
<Book textColor="#FFF"
|
||||
bgColor="#FF4F01"
|
||||
@@ -519,21 +540,4 @@ export default component$(() => {
|
||||
</section>
|
||||
</div>
|
||||
</MotionComponent>
|
||||
<Footer client:load>
|
||||
<div class="w-full flex justify-center flex-col items-center gap-3">
|
||||
<Link href="https://discord.gg/6um5K6jrYj" prefetch={false} class="flex font-bricolage text-sm sm:text-base rounded-full bg-primary-500 px-5 py-4 font-semibold text-white transition-all hover:scale-105 active:scale-95 sm:px-6" >
|
||||
Join our Discord
|
||||
</Link>
|
||||
<div class="mt-6 flex w-full items-center justify-center gap-2 text-xs sm:text-sm font-medium text-neutral-600 dark:text-neutral-400">
|
||||
<span class="hover:text-primary-500 transition-colors duration-200">
|
||||
<Link rel="noreferrer" href="/terms" >Terms of Service</Link></span>
|
||||
<span class="text-gray-400 dark:text-gray-600">•</span>
|
||||
<span class="hover:text-primary-500 transition-colors duration-200" >
|
||||
<Link href="/privacy">Privacy Policy</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Footer>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
*/
|
||||
@@ -132,11 +132,9 @@ RUN sed -i \
|
||||
# Core system components
|
||||
RUN --mount=type=cache,target=/var/cache/pacman/pkg \
|
||||
pacman -Sy --needed --noconfirm \
|
||||
vulkan-intel lib32-vulkan-intel vpl-gpu-rt \
|
||||
vulkan-radeon lib32-vulkan-radeon \
|
||||
mesa \
|
||||
vulkan-intel lib32-vulkan-intel vpl-gpu-rt mesa \
|
||||
steam steam-native-runtime gtk3 lib32-gtk3 \
|
||||
sudo xorg-xwayland seatd libinput gamescope mangohud \
|
||||
sudo xorg-xwayland seatd libinput labwc wlr-randr gamescope mangohud \
|
||||
libssh2 curl wget \
|
||||
pipewire pipewire-pulse pipewire-alsa wireplumber \
|
||||
noto-fonts-cjk supervisor jq chwd lshw pacman-contrib && \
|
||||
|
||||
1
docker-compose.yml
Normal file
@@ -0,0 +1 @@
|
||||
#FIXME: A simple docker-compose file for running the MoQ relay and the cachyos server
|
||||
53
infra/api.ts
@@ -1,21 +1,19 @@
|
||||
import { bus } from "./bus";
|
||||
import { auth } from "./auth";
|
||||
import { domain } from "./dns";
|
||||
import { secret } from "./secret";
|
||||
import { cluster } from "./cluster";
|
||||
import { postgres } from "./postgres";
|
||||
import { LibraryQueue } from "./steam";
|
||||
import { secret, steamEncryptionKey } from "./secret";
|
||||
|
||||
export const apiService = new sst.aws.Service("Api", {
|
||||
export const api = new sst.aws.Service("Api", {
|
||||
cluster,
|
||||
cpu: $app.stage === "production" ? "2 vCPU" : undefined,
|
||||
memory: $app.stage === "production" ? "4 GB" : undefined,
|
||||
command: ["bun", "run", "./src/api/index.ts"],
|
||||
link: [
|
||||
bus,
|
||||
auth,
|
||||
postgres,
|
||||
LibraryQueue,
|
||||
steamEncryptionKey,
|
||||
secret.PolarSecret,
|
||||
secret.PolarWebhookSecret,
|
||||
secret.NestriFamilyMonthly,
|
||||
@@ -24,10 +22,12 @@ export const apiService = new sst.aws.Service("Api", {
|
||||
secret.NestriProMonthly,
|
||||
secret.NestriProYearly,
|
||||
],
|
||||
command: ["bun", "run", "./src/api/index.ts"],
|
||||
image: {
|
||||
dockerfile: "packages/functions/Containerfile",
|
||||
},
|
||||
environment: {
|
||||
NO_COLOR: "1",
|
||||
},
|
||||
loadBalancer: {
|
||||
rules: [
|
||||
{
|
||||
@@ -37,9 +37,9 @@ export const apiService = new sst.aws.Service("Api", {
|
||||
],
|
||||
},
|
||||
dev: {
|
||||
url: "http://localhost:3001",
|
||||
command: "bun dev:api",
|
||||
directory: "packages/functions",
|
||||
url: "http://localhost:3001",
|
||||
},
|
||||
scaling:
|
||||
$app.stage === "production"
|
||||
@@ -48,49 +48,16 @@ export const apiService = new sst.aws.Service("Api", {
|
||||
max: 10,
|
||||
}
|
||||
: undefined,
|
||||
// For persisting actor state
|
||||
transform: {
|
||||
taskDefinition: (args) => {
|
||||
const volumes = $output(args.volumes).apply(v => {
|
||||
const next = [...v, {
|
||||
name: "shared-tmp",
|
||||
dockerVolumeConfiguration: {
|
||||
scope: "shared",
|
||||
driver: "local"
|
||||
}
|
||||
}];
|
||||
|
||||
return next;
|
||||
})
|
||||
|
||||
// "containerDefinitions" is a JSON string, parse first
|
||||
let containers = $jsonParse(args.containerDefinitions);
|
||||
|
||||
containers = containers.apply((containerDefinitions) => {
|
||||
containerDefinitions[0].mountPoints = [
|
||||
...(containerDefinitions[0].mountPoints ?? []),
|
||||
{
|
||||
sourceVolume: "shared-tmp",
|
||||
containerPath: "/tmp"
|
||||
},
|
||||
]
|
||||
return containerDefinitions;
|
||||
});
|
||||
|
||||
args.volumes = volumes
|
||||
args.containerDefinitions = $jsonStringify(containers);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
export const api = !$dev ? new sst.aws.Router("ApiRoute", {
|
||||
export const apiRoute = new sst.aws.Router("ApiRoute", {
|
||||
routes: {
|
||||
// I think api.url should work all the same
|
||||
"/*": apiService.nodes.loadBalancer.dnsName,
|
||||
"/*": api.nodes.loadBalancer.dnsName,
|
||||
},
|
||||
domain: {
|
||||
name: "api." + domain,
|
||||
dns: sst.cloudflare.dns(),
|
||||
},
|
||||
}) : apiService
|
||||
})
|
||||
@@ -1,19 +1,19 @@
|
||||
import { bus } from "./bus";
|
||||
import { domain } from "./dns";
|
||||
import { secret } from "./secret";
|
||||
import { cluster } from "./cluster";
|
||||
import { postgres } from "./postgres";
|
||||
import { secret, steamEncryptionKey } from "./secret";
|
||||
|
||||
export const authService = new sst.aws.Service("Auth", {
|
||||
//FIXME: Use a shared /tmp folder
|
||||
export const auth = new sst.aws.Service("Auth", {
|
||||
cluster,
|
||||
cpu: $app.stage === "production" ? "1 vCPU" : undefined,
|
||||
memory: $app.stage === "production" ? "2 GB" : undefined,
|
||||
command: ["bun", "run", "./src/auth/index.ts"],
|
||||
command: ["bun", "run", "./src/auth.ts"],
|
||||
link: [
|
||||
bus,
|
||||
postgres,
|
||||
secret.PolarSecret,
|
||||
steamEncryptionKey,
|
||||
secret.GithubClientID,
|
||||
secret.DiscordClientID,
|
||||
secret.GithubClientSecret,
|
||||
@@ -24,7 +24,7 @@ export const authService = new sst.aws.Service("Auth", {
|
||||
},
|
||||
environment: {
|
||||
NO_COLOR: "1",
|
||||
STORAGE: "/tmp/persist.json"
|
||||
STORAGE: $dev ? "/tmp/persist.json" : "/mnt/efs/persist.json"
|
||||
},
|
||||
loadBalancer: {
|
||||
rules: [
|
||||
@@ -52,48 +52,15 @@ export const authService = new sst.aws.Service("Auth", {
|
||||
max: 10,
|
||||
}
|
||||
: undefined,
|
||||
//For temporarily persisting the persist.json
|
||||
transform: {
|
||||
taskDefinition: (args) => {
|
||||
const volumes = $output(args.volumes).apply(v => {
|
||||
const next = [...v, {
|
||||
name: "shared-tmp",
|
||||
dockerVolumeConfiguration: {
|
||||
scope: "shared",
|
||||
driver: "local"
|
||||
}
|
||||
}];
|
||||
|
||||
return next;
|
||||
})
|
||||
|
||||
// "containerDefinitions" is a JSON string, parse first
|
||||
let containers = $jsonParse(args.containerDefinitions);
|
||||
|
||||
containers = containers.apply((containerDefinitions) => {
|
||||
containerDefinitions[0].mountPoints = [
|
||||
...(containerDefinitions[0].mountPoints ?? []),
|
||||
{
|
||||
sourceVolume: "shared-tmp",
|
||||
containerPath: "/tmp"
|
||||
}
|
||||
]
|
||||
return containerDefinitions;
|
||||
});
|
||||
|
||||
args.volumes = volumes
|
||||
args.containerDefinitions = $jsonStringify(containers);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const auth = !$dev ? new sst.aws.Router("AuthRoute", {
|
||||
export const authRoute = new sst.aws.Router("AuthRoute", {
|
||||
routes: {
|
||||
// I think auth.url should work all the same
|
||||
"/*": authService.nodes.loadBalancer.dnsName,
|
||||
"/*": auth.nodes.loadBalancer.dnsName,
|
||||
},
|
||||
domain: {
|
||||
name: "auth." + domain,
|
||||
dns: sst.cloudflare.dns(),
|
||||
},
|
||||
}) : authService
|
||||
})
|
||||
11
infra/bus.ts
@@ -1,22 +1,19 @@
|
||||
import { vpc } from "./vpc";
|
||||
import { storage } from "./storage";
|
||||
// import { email } from "./email";
|
||||
import { allSecrets } from "./secret";
|
||||
import { postgres } from "./postgres";
|
||||
import { steamEncryptionKey } from "./secret";
|
||||
|
||||
export const bus = new sst.aws.Bus("Bus");
|
||||
|
||||
bus.subscribe("Event", {
|
||||
vpc,
|
||||
handler: "packages/functions/src/events/index.handler",
|
||||
handler: "./packages/functions/src/event/event.handler",
|
||||
link: [
|
||||
// email,
|
||||
postgres,
|
||||
storage,
|
||||
steamEncryptionKey
|
||||
...allSecrets
|
||||
],
|
||||
timeout: "10 minutes",
|
||||
memory: "3002 MB",// For faster processing of large(r) images
|
||||
timeout: "5 minutes",
|
||||
permissions: [
|
||||
{
|
||||
actions: ["ses:SendEmail"],
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { vpc } from "./vpc";
|
||||
import { isPermanentStage } from "./stage";
|
||||
import { steamEncryptionKey } from "./secret";
|
||||
|
||||
// TODO: Add a dev db to use, this will help with running zero locally... and testing it
|
||||
export const postgres = new sst.aws.Aurora("Database", {
|
||||
@@ -42,7 +41,7 @@ export const postgres = new sst.aws.Aurora("Database", {
|
||||
|
||||
|
||||
new sst.x.DevCommand("Studio", {
|
||||
link: [postgres, steamEncryptionKey],
|
||||
link: [postgres],
|
||||
dev: {
|
||||
command: "bun db:dev studio",
|
||||
directory: "packages/core",
|
||||
@@ -50,20 +49,20 @@ new sst.x.DevCommand("Studio", {
|
||||
},
|
||||
});
|
||||
|
||||
// const migrator = new sst.aws.Function("DatabaseMigrator", {
|
||||
// handler: "packages/functions/src/migrator.handler",
|
||||
// link: [postgres],
|
||||
// copyFiles: [
|
||||
// {
|
||||
// from: "packages/core/migrations",
|
||||
// to: "./migrations",
|
||||
// },
|
||||
// ],
|
||||
// });
|
||||
const migrator = new sst.aws.Function("DatabaseMigrator", {
|
||||
handler: "packages/functions/src/migrator.handler",
|
||||
link: [postgres],
|
||||
copyFiles: [
|
||||
{
|
||||
from: "packages/core/migrations",
|
||||
to: "./migrations",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// if (!$dev) {
|
||||
// new aws.lambda.Invocation("DatabaseMigratorInvocation", {
|
||||
// input: Date.now().toString(),
|
||||
// functionName: migrator.name,
|
||||
// });
|
||||
// }
|
||||
if (!$dev) {
|
||||
new aws.lambda.Invocation("DatabaseMigratorInvocation", {
|
||||
input: Date.now().toString(),
|
||||
functionName: migrator.name,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,16 +15,3 @@ export const secret = {
|
||||
};
|
||||
|
||||
export const allSecrets = Object.values(secret);
|
||||
|
||||
sst.Linkable.wrap(random.RandomString, (resource) => ({
|
||||
properties: {
|
||||
value: resource.result,
|
||||
},
|
||||
}));
|
||||
|
||||
export const steamEncryptionKey = new random.RandomString(
|
||||
"SteamEncryptionKey",
|
||||
{
|
||||
length: 32,
|
||||
},
|
||||
);
|
||||
@@ -1,19 +1,7 @@
|
||||
import { vpc } from "./vpc";
|
||||
import { postgres } from "./postgres";
|
||||
import { steamEncryptionKey } from "./secret";
|
||||
|
||||
export const LibraryQueue = new sst.aws.Queue("LibraryQueue", {
|
||||
fifo: true,
|
||||
visibilityTimeout: "10 minutes",
|
||||
});
|
||||
|
||||
LibraryQueue.subscribe({
|
||||
vpc,
|
||||
timeout: "10 minutes",
|
||||
memory: "3002 MB",
|
||||
handler: "packages/functions/src/queues/library.handler",
|
||||
link: [
|
||||
postgres,
|
||||
steamEncryptionKey
|
||||
],
|
||||
new sst.x.DevCommand("Steam", {
|
||||
dev: {
|
||||
command: "bun dev",
|
||||
directory: "packages/steam",
|
||||
autostart: true,
|
||||
},
|
||||
});
|
||||
@@ -26,14 +26,13 @@ const zeroEnv = {
|
||||
ZERO_CHANGE_DB: connectionString,
|
||||
ZERO_REPLICA_FILE: "/tmp/nestri.db",
|
||||
ZERO_LITESTREAM_RESTORE_PARALLELISM: "64",
|
||||
ZERO_APP_ID: $app.stage,
|
||||
ZERO_SHARD_ID: $app.stage,
|
||||
ZERO_AUTH_JWKS_URL: $interpolate`${auth.url}/.well-known/jwks.json`,
|
||||
NODE_OPTIONS: "--max-old-space-size=8192",
|
||||
...($dev
|
||||
? {
|
||||
}
|
||||
: {
|
||||
ZERO_LITESTREAM_BACKUP_URL: $interpolate`s3://${storage.name}/zero/0`,
|
||||
ZERO_LITESTREAM_BACKUP_URL: $interpolate`s3://${storage.name}/zero`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -85,44 +84,44 @@ const replicationManager = !$dev
|
||||
}) : undefined;
|
||||
|
||||
// Permissions deployment
|
||||
// const permissions = new sst.aws.Function(
|
||||
// "ZeroPermissions",
|
||||
// {
|
||||
// vpc,
|
||||
// link: [postgres],
|
||||
// handler: "packages/functions/src/zero.handler",
|
||||
// // environment: { ["ZERO_UPSTREAM_DB"]: connectionString },
|
||||
// copyFiles: [{
|
||||
// from: "packages/zero/permissions.sql",
|
||||
// to: "./.permissions.sql"
|
||||
// }],
|
||||
// }
|
||||
// );
|
||||
const permissions = new sst.aws.Function(
|
||||
"ZeroPermissions",
|
||||
{
|
||||
vpc,
|
||||
link: [postgres],
|
||||
handler: "packages/functions/src/zero.handler",
|
||||
// environment: { ["ZERO_UPSTREAM_DB"]: connectionString },
|
||||
copyFiles: [{
|
||||
from: "packages/zero/.permissions.sql",
|
||||
to: "./.permissions.sql"
|
||||
}],
|
||||
}
|
||||
);
|
||||
|
||||
// if (replicationManager) {
|
||||
// new aws.lambda.Invocation(
|
||||
// "ZeroPermissionsInvocation",
|
||||
// {
|
||||
// input: Date.now().toString(),
|
||||
// functionName: permissions.name,
|
||||
// },
|
||||
// { dependsOn: replicationManager }
|
||||
// );
|
||||
// // new command.local.Command(
|
||||
// // "ZeroPermission",
|
||||
// // {
|
||||
// // dir: process.cwd() + "/packages/zero",
|
||||
// // environment: {
|
||||
// // ZERO_UPSTREAM_DB: connectionString,
|
||||
// // },
|
||||
// // create: "bun run zero-deploy-permissions",
|
||||
// // triggers: [Date.now()],
|
||||
// // },
|
||||
// // {
|
||||
// // dependsOn: [replicationManager],
|
||||
// // },
|
||||
// // );
|
||||
// }
|
||||
if (replicationManager) {
|
||||
new aws.lambda.Invocation(
|
||||
"ZeroPermissionsInvocation",
|
||||
{
|
||||
input: Date.now().toString(),
|
||||
functionName: permissions.name,
|
||||
},
|
||||
{ dependsOn: replicationManager }
|
||||
);
|
||||
// new command.local.Command(
|
||||
// "ZeroPermission",
|
||||
// {
|
||||
// dir: process.cwd() + "/packages/zero",
|
||||
// environment: {
|
||||
// ZERO_UPSTREAM_DB: connectionString,
|
||||
// },
|
||||
// create: "bun run zero-deploy-permissions",
|
||||
// triggers: [Date.now()],
|
||||
// },
|
||||
// {
|
||||
// dependsOn: [replicationManager],
|
||||
// },
|
||||
// );
|
||||
}
|
||||
|
||||
export const zero = new sst.aws.Service("Zero", {
|
||||
cluster,
|
||||
@@ -152,6 +151,7 @@ export const zero = new sst.aws.Service("Zero", {
|
||||
ZERO_CVR_MAX_CONNS: "160",
|
||||
}),
|
||||
},
|
||||
wait: true,
|
||||
health: {
|
||||
retries: 3,
|
||||
command: ["CMD-SHELL", "curl -f http://localhost:4848/ || exit 1"],
|
||||
|
||||
29
nestri.sln
Normal file
@@ -0,0 +1,29 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.5.2.0
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "packages", "packages", "{809F86A1-1C4C-B159-0CD4-DF9D33D876CE}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "steam", "packages\steam\steam.csproj", "{96118F95-BF02-0ED3-9042-36FA1B740D67}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{96118F95-BF02-0ED3-9042-36FA1B740D67}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{96118F95-BF02-0ED3-9042-36FA1B740D67} = {809F86A1-1C4C-B159-0CD4-DF9D33D876CE}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {526AD703-4D15-43CF-B7C0-83F10D3158DB}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -18,13 +18,11 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@openauthjs/openauth": "0.4.3",
|
||||
"@rocicorp/zero": "0.20.2025050901",
|
||||
"steam-session": "1.9.3"
|
||||
"@rocicorp/zero": "0.16.2025022000"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@macaron-css/solid@1.5.3": "patches/@macaron-css%2Fsolid@1.5.3.patch",
|
||||
"drizzle-orm@0.36.1": "patches/drizzle-orm@0.36.1.patch",
|
||||
"steam-session@1.9.3": "patches/steam-session@1.9.3.patch"
|
||||
"drizzle-orm@0.36.1": "patches/drizzle-orm@0.36.1.patch"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"core-js-pure",
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
CREATE TYPE "public"."member_role" AS ENUM('child', 'adult');--> statement-breakpoint
|
||||
CREATE TYPE "public"."steam_status" AS ENUM('online', 'offline', 'dnd', 'playing');--> statement-breakpoint
|
||||
CREATE TABLE "steam_account_credentials" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"steam_id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"refresh_token" text NOT NULL,
|
||||
"expiry" timestamp with time zone NOT NULL,
|
||||
"username" varchar(255) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "friends_list" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"steam_id" varchar(255) NOT NULL,
|
||||
"friend_steam_id" varchar(255) NOT NULL,
|
||||
CONSTRAINT "friends_list_steam_id_friend_steam_id_pk" PRIMARY KEY("steam_id","friend_steam_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "members" (
|
||||
"id" char(30) NOT NULL,
|
||||
"team_id" char(30) NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"user_id" char(30),
|
||||
"steam_id" varchar(255) NOT NULL,
|
||||
"role" "member_role" NOT NULL,
|
||||
CONSTRAINT "members_id_team_id_pk" PRIMARY KEY("id","team_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "steam_accounts" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"steam_id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"user_id" char(30),
|
||||
"status" "steam_status" NOT NULL,
|
||||
"last_synced_at" timestamp with time zone NOT NULL,
|
||||
"real_name" varchar(255),
|
||||
"member_since" timestamp with time zone NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"profile_url" varchar(255),
|
||||
"username" varchar(255) NOT NULL,
|
||||
"avatar_hash" varchar(255) NOT NULL,
|
||||
"limitations" json NOT NULL,
|
||||
CONSTRAINT "idx_steam_username" UNIQUE("username")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "teams" (
|
||||
"id" char(30) PRIMARY KEY NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"owner_id" char(30) NOT NULL,
|
||||
"invite_code" varchar(10) NOT NULL,
|
||||
"slug" varchar(255) NOT NULL,
|
||||
"max_members" bigint NOT NULL,
|
||||
CONSTRAINT "idx_team_invite_code" UNIQUE("invite_code")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" char(30) PRIMARY KEY NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"email" varchar(255) NOT NULL,
|
||||
"avatar_url" text,
|
||||
"last_login" timestamp with time zone NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"polar_customer_id" varchar(255),
|
||||
CONSTRAINT "idx_user_email" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DROP TABLE "machine" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "member" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "steam" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "subscription" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "team" CASCADE;--> statement-breakpoint
|
||||
DROP TABLE "user" CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "steam_account_credentials" ADD CONSTRAINT "steam_account_credentials_steam_id_steam_accounts_steam_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("steam_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" ADD CONSTRAINT "friends_list_steam_id_steam_accounts_steam_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("steam_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" ADD CONSTRAINT "friends_list_friend_steam_id_steam_accounts_steam_id_fk" FOREIGN KEY ("friend_steam_id") REFERENCES "public"."steam_accounts"("steam_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "members" ADD CONSTRAINT "members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "members" ADD CONSTRAINT "members_steam_id_steam_accounts_steam_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("steam_id") ON DELETE cascade ON UPDATE restrict;--> statement-breakpoint
|
||||
ALTER TABLE "steam_accounts" ADD CONSTRAINT "steam_accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "teams" ADD CONSTRAINT "teams_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "teams" ADD CONSTRAINT "teams_slug_steam_accounts_username_fk" FOREIGN KEY ("slug") REFERENCES "public"."steam_accounts"("username") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_member_steam_id" ON "members" USING btree ("team_id","steam_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_member_user_id" ON "members" USING btree ("team_id","user_id") WHERE "members"."user_id" is not null;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "idx_team_slug" ON "teams" USING btree ("slug");
|
||||
@@ -1,89 +0,0 @@
|
||||
CREATE TYPE "public"."compatibility" AS ENUM('high', 'mid', 'low', 'unknown');--> statement-breakpoint
|
||||
CREATE TYPE "public"."category_type" AS ENUM('tag', 'genre', 'publisher', 'developer');--> statement-breakpoint
|
||||
CREATE TYPE "public"."image_type" AS ENUM('heroArt', 'icon', 'logo', 'superHeroArt', 'poster', 'boxArt', 'screenshot', 'background');--> statement-breakpoint
|
||||
CREATE TABLE "base_games" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"id" varchar(255) PRIMARY KEY NOT NULL,
|
||||
"slug" varchar(255) NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"release_date" timestamp with time zone NOT NULL,
|
||||
"size" json NOT NULL,
|
||||
"description" text NOT NULL,
|
||||
"primary_genre" text NOT NULL,
|
||||
"controller_support" text,
|
||||
"compatibility" "compatibility" DEFAULT 'unknown' NOT NULL,
|
||||
"score" numeric(2, 1) NOT NULL,
|
||||
CONSTRAINT "idx_base_games_slug" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "categories" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"slug" varchar(255) NOT NULL,
|
||||
"type" "category_type" NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
CONSTRAINT "categories_slug_type_pk" PRIMARY KEY("slug","type")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "games" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"base_game_id" varchar(255) NOT NULL,
|
||||
"category_slug" varchar(255) NOT NULL,
|
||||
"type" "category_type" NOT NULL,
|
||||
CONSTRAINT "games_base_game_id_category_slug_type_pk" PRIMARY KEY("base_game_id","category_slug","type")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "images" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"type" "image_type" NOT NULL,
|
||||
"image_hash" varchar(255) NOT NULL,
|
||||
"base_game_id" varchar(255) NOT NULL,
|
||||
"source_url" text NOT NULL,
|
||||
"position" integer DEFAULT 0 NOT NULL,
|
||||
"file_size" integer NOT NULL,
|
||||
"dimensions" json NOT NULL,
|
||||
"extracted_color" json NOT NULL,
|
||||
CONSTRAINT "images_image_hash_type_base_game_id_position_pk" PRIMARY KEY("image_hash","type","base_game_id","position")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "game_libraries" (
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"base_game_id" varchar(255) NOT NULL,
|
||||
"owner_id" varchar(255) NOT NULL,
|
||||
CONSTRAINT "game_libraries_base_game_id_owner_id_pk" PRIMARY KEY("base_game_id","owner_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "steam_accounts" RENAME COLUMN "steam_id" TO "id";--> statement-breakpoint
|
||||
ALTER TABLE "steam_account_credentials" DROP CONSTRAINT "steam_account_credentials_steam_id_steam_accounts_steam_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" DROP CONSTRAINT "friends_list_steam_id_steam_accounts_steam_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" DROP CONSTRAINT "friends_list_friend_steam_id_steam_accounts_steam_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "members" DROP CONSTRAINT "members_steam_id_steam_accounts_steam_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "games" ADD CONSTRAINT "games_base_game_id_base_games_id_fk" FOREIGN KEY ("base_game_id") REFERENCES "public"."base_games"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "games" ADD CONSTRAINT "games_categories_fkey" FOREIGN KEY ("category_slug","type") REFERENCES "public"."categories"("slug","type") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "images" ADD CONSTRAINT "images_base_game_id_base_games_id_fk" FOREIGN KEY ("base_game_id") REFERENCES "public"."base_games"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD CONSTRAINT "game_libraries_base_game_id_base_games_id_fk" FOREIGN KEY ("base_game_id") REFERENCES "public"."base_games"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD CONSTRAINT "game_libraries_owner_id_steam_accounts_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_categories_type" ON "categories" USING btree ("type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_games_category_slug" ON "games" USING btree ("category_slug");--> statement-breakpoint
|
||||
CREATE INDEX "idx_games_category_type" ON "games" USING btree ("type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_images_type" ON "images" USING btree ("type");--> statement-breakpoint
|
||||
CREATE INDEX "idx_images_game_id" ON "images" USING btree ("base_game_id");--> statement-breakpoint
|
||||
CREATE INDEX "idx_game_libraries_owner_id" ON "game_libraries" USING btree ("owner_id");--> statement-breakpoint
|
||||
ALTER TABLE "steam_account_credentials" ADD CONSTRAINT "steam_account_credentials_steam_id_steam_accounts_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" ADD CONSTRAINT "friends_list_steam_id_steam_accounts_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "friends_list" ADD CONSTRAINT "friends_list_friend_steam_id_steam_accounts_id_fk" FOREIGN KEY ("friend_steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "members" ADD CONSTRAINT "members_steam_id_steam_accounts_id_fk" FOREIGN KEY ("steam_id") REFERENCES "public"."steam_accounts"("id") ON DELETE cascade ON UPDATE restrict;--> statement-breakpoint
|
||||
CREATE INDEX "idx_friends_list_friend_steam_id" ON "friends_list" USING btree ("friend_steam_id");
|
||||
@@ -1,4 +0,0 @@
|
||||
ALTER TABLE "games" DROP CONSTRAINT "games_categories_fkey";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "games" ADD CONSTRAINT "games_categories_fkey" FOREIGN KEY ("category_slug","type") REFERENCES "public"."categories"("slug","type") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "idx_games_category_slug_type" ON "games" USING btree ("category_slug","type");
|
||||
@@ -1,3 +0,0 @@
|
||||
CREATE TYPE "public"."controller_support" AS ENUM('full', 'unknown');--> statement-breakpoint
|
||||
ALTER TABLE "base_games" ALTER COLUMN "controller_support" SET DATA TYPE controller_support;--> statement-breakpoint
|
||||
ALTER TABLE "base_games" ALTER COLUMN "controller_support" SET NOT NULL;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE "base_games" ALTER COLUMN "primary_genre" DROP NOT NULL;
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TYPE "public"."controller_support" ADD VALUE 'partial' BEFORE 'unknown';
|
||||
@@ -1,4 +0,0 @@
|
||||
ALTER TABLE "game_libraries" ADD COLUMN "time_acquired" timestamp with time zone NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD COLUMN "last_played" timestamp with time zone NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD COLUMN "total_playtime" integer NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "game_libraries" ADD COLUMN "is_family_shared" boolean NOT NULL;
|
||||
@@ -1,4 +0,0 @@
|
||||
ALTER TABLE "public"."images" ALTER COLUMN "type" SET DATA TYPE text;--> statement-breakpoint
|
||||
DROP TYPE "public"."image_type";--> statement-breakpoint
|
||||
CREATE TYPE "public"."image_type" AS ENUM('heroArt', 'icon', 'logo', 'superHeroArt', 'poster', 'boxArt', 'screenshot', 'backdrop');--> statement-breakpoint
|
||||
ALTER TABLE "public"."images" ALTER COLUMN "type" SET DATA TYPE "public"."image_type" USING "type"::"public"."image_type";
|
||||
@@ -1,4 +0,0 @@
|
||||
ALTER TABLE "public"."images" ALTER COLUMN "type" SET DATA TYPE text;--> statement-breakpoint
|
||||
DROP TYPE "public"."image_type";--> statement-breakpoint
|
||||
CREATE TYPE "public"."image_type" AS ENUM('heroArt', 'icon', 'logo', 'banner', 'poster', 'boxArt', 'screenshot', 'backdrop');--> statement-breakpoint
|
||||
ALTER TABLE "public"."images" ALTER COLUMN "type" SET DATA TYPE "public"."image_type" USING "type"::"public"."image_type";
|
||||
@@ -1,19 +0,0 @@
|
||||
/*
|
||||
Unfortunately in current drizzle-kit version we can't automatically get name for primary key.
|
||||
We are working on making it available!
|
||||
|
||||
Meanwhile you can:
|
||||
1. Check pk name in your database, by running
|
||||
SELECT constraint_name FROM information_schema.table_constraints
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'steam_account_credentials'
|
||||
AND constraint_type = 'PRIMARY KEY';
|
||||
2. Uncomment code below and paste pk name manually
|
||||
|
||||
Hope to release this update as soon as possible
|
||||
*/
|
||||
|
||||
-- ALTER TABLE "steam_account_credentials" DROP CONSTRAINT "<constraint_name>";--> statement-breakpoint
|
||||
ALTER TABLE "images" ALTER COLUMN "source_url" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "steam_account_credentials" ADD CONSTRAINT "steam_account_credentials_steam_id_id_pk" PRIMARY KEY("steam_id","id");--> statement-breakpoint
|
||||
ALTER TABLE "steam_account_credentials" ADD COLUMN "id" char(30) NOT NULL;
|
||||
@@ -1,651 +0,0 @@
|
||||
{
|
||||
"id": "56a4d60a-c062-47e5-a97e-625443411ad8",
|
||||
"prevId": "1717c769-cee0-4242-bcbb-9538c80d985c",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.steam_account_credentials": {
|
||||
"name": "steam_account_credentials",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steam_id": {
|
||||
"name": "steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expiry": {
|
||||
"name": "expiry",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"steam_account_credentials_steam_id_steam_accounts_steam_id_fk": {
|
||||
"name": "steam_account_credentials_steam_id_steam_accounts_steam_id_fk",
|
||||
"tableFrom": "steam_account_credentials",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"steam_id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.friends_list": {
|
||||
"name": "friends_list",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steam_id": {
|
||||
"name": "steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"friend_steam_id": {
|
||||
"name": "friend_steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"friends_list_steam_id_steam_accounts_steam_id_fk": {
|
||||
"name": "friends_list_steam_id_steam_accounts_steam_id_fk",
|
||||
"tableFrom": "friends_list",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"steam_id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"friends_list_friend_steam_id_steam_accounts_steam_id_fk": {
|
||||
"name": "friends_list_friend_steam_id_steam_accounts_steam_id_fk",
|
||||
"tableFrom": "friends_list",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"friend_steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"steam_id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"friends_list_steam_id_friend_steam_id_pk": {
|
||||
"name": "friends_list_steam_id_friend_steam_id_pk",
|
||||
"columns": [
|
||||
"steam_id",
|
||||
"friend_steam_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.members": {
|
||||
"name": "members",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"team_id": {
|
||||
"name": "team_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steam_id": {
|
||||
"name": "steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "member_role",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_member_steam_id": {
|
||||
"name": "idx_member_steam_id",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "team_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "steam_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"idx_member_user_id": {
|
||||
"name": "idx_member_user_id",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "team_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"where": "\"members\".\"user_id\" is not null",
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"members_user_id_users_id_fk": {
|
||||
"name": "members_user_id_users_id_fk",
|
||||
"tableFrom": "members",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"members_steam_id_steam_accounts_steam_id_fk": {
|
||||
"name": "members_steam_id_steam_accounts_steam_id_fk",
|
||||
"tableFrom": "members",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"steam_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"steam_id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "restrict"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"members_id_team_id_pk": {
|
||||
"name": "members_id_team_id_pk",
|
||||
"columns": [
|
||||
"id",
|
||||
"team_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.steam_accounts": {
|
||||
"name": "steam_accounts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"steam_id": {
|
||||
"name": "steam_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "steam_status",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_synced_at": {
|
||||
"name": "last_synced_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"real_name": {
|
||||
"name": "real_name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"member_since": {
|
||||
"name": "member_since",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"profile_url": {
|
||||
"name": "profile_url",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"avatar_hash": {
|
||||
"name": "avatar_hash",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"limitations": {
|
||||
"name": "limitations",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"steam_accounts_user_id_users_id_fk": {
|
||||
"name": "steam_accounts_user_id_users_id_fk",
|
||||
"tableFrom": "steam_accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"idx_steam_username": {
|
||||
"name": "idx_steam_username",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.teams": {
|
||||
"name": "teams",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"owner_id": {
|
||||
"name": "owner_id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"invite_code": {
|
||||
"name": "invite_code",
|
||||
"type": "varchar(10)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"max_members": {
|
||||
"name": "max_members",
|
||||
"type": "bigint",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"idx_team_slug": {
|
||||
"name": "idx_team_slug",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "slug",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"teams_owner_id_users_id_fk": {
|
||||
"name": "teams_owner_id_users_id_fk",
|
||||
"tableFrom": "teams",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"owner_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"teams_slug_steam_accounts_username_fk": {
|
||||
"name": "teams_slug_steam_accounts_username_fk",
|
||||
"tableFrom": "teams",
|
||||
"tableTo": "steam_accounts",
|
||||
"columnsFrom": [
|
||||
"slug"
|
||||
],
|
||||
"columnsTo": [
|
||||
"username"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"idx_team_invite_code": {
|
||||
"name": "idx_team_invite_code",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"invite_code"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "char(30)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"time_created": {
|
||||
"name": "time_created",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_updated": {
|
||||
"name": "time_updated",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"time_deleted": {
|
||||
"name": "time_deleted",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"avatar_url": {
|
||||
"name": "avatar_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"last_login": {
|
||||
"name": "last_login",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"polar_customer_id": {
|
||||
"name": "polar_customer_id",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"idx_user_email": {
|
||||
"name": "idx_user_email",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.member_role": {
|
||||
"name": "member_role",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"child",
|
||||
"adult"
|
||||
]
|
||||
},
|
||||
"public.steam_status": {
|
||||
"name": "steam_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"online",
|
||||
"offline",
|
||||
"dnd",
|
||||
"playing"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -71,76 +71,6 @@
|
||||
"when": 1744651817581,
|
||||
"tag": "0009_luxuriant_wraith",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1746726715456,
|
||||
"tag": "0010_certain_dust",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1746904821461,
|
||||
"tag": "0011_simple_azazel",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1746905730079,
|
||||
"tag": "0012_glorious_jetstream",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1746925065142,
|
||||
"tag": "0013_neat_colleen_wing",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1746926498096,
|
||||
"tag": "0014_thin_groot",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1746928882281,
|
||||
"tag": "0015_handy_giant_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1747032794033,
|
||||
"tag": "0016_melted_johnny_storm",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1747034424687,
|
||||
"tag": "0017_zippy_nico_minoru",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1747073173196,
|
||||
"tag": "0018_solid_enchantress",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1747202158003,
|
||||
"tag": "0019_charming_namorita",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,33 +15,26 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node20": "^20.1.4",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"aws-iot-device-sdk-v2": "^1.21.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"loops": "^3.4.1",
|
||||
"mqtt": "^5.10.3",
|
||||
"remeda": "^2.21.2",
|
||||
"ulid": "^2.3.0",
|
||||
"uuid": "^11.0.3",
|
||||
"zod": "^3.24.1",
|
||||
"zod-openapi": "^4.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-iot-data-plane": "^3.758.0",
|
||||
"@aws-sdk/client-rds-data": "^3.758.0",
|
||||
"@aws-sdk/client-sesv2": "^3.753.0",
|
||||
"@instantdb/admin": "^0.17.7",
|
||||
"@openauthjs/openauth": "*",
|
||||
"@openauthjs/openevent": "^0.0.27",
|
||||
"@polar-sh/sdk": "^0.26.1",
|
||||
"drizzle-kit": "^0.30.5",
|
||||
"drizzle-orm": "^0.40.0",
|
||||
"drizzle-zod": "^0.7.1",
|
||||
"fast-average-color": "^9.5.0",
|
||||
"lru-cache": "^11.1.0",
|
||||
"p-limit": "^6.2.0",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"postgres": "^3.4.5",
|
||||
"sanitize-html": "^2.16.0",
|
||||
"sharp": "^0.34.1",
|
||||
"steam-session": "*"
|
||||
"postgres": "^3.4.5"
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { z } from "zod"
|
||||
import { User } from "../user";
|
||||
import { Team } from "../team";
|
||||
import { Actor } from "../actor";
|
||||
import { Examples } from "../examples";
|
||||
import { ErrorCodes, VisibleError } from "../error";
|
||||
|
||||
export namespace Account {
|
||||
export const Info =
|
||||
User.Info
|
||||
.extend({
|
||||
teams: Team.Info
|
||||
.array()
|
||||
.openapi({
|
||||
description: "The teams that this user is part of",
|
||||
example: [Examples.Team]
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Account",
|
||||
description: "Represents an account's information stored on Nestri",
|
||||
example: { ...Examples.User, teams: [Examples.Team] },
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const list = async (): Promise<Info> => {
|
||||
const [userResult, teamsResult] =
|
||||
await Promise.allSettled([
|
||||
User.fromID(Actor.userID()),
|
||||
Team.list()
|
||||
])
|
||||
|
||||
if (userResult.status === "rejected" || !userResult.value)
|
||||
throw new VisibleError(
|
||||
"not_found",
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
"User not found",
|
||||
);
|
||||
|
||||
return {
|
||||
...userResult.value,
|
||||
teams: teamsResult.status === "rejected" ? [] : teamsResult.value
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,131 +1,142 @@
|
||||
import { Log } from "./utils";
|
||||
import { createContext } from "./context";
|
||||
import { z } from "zod";
|
||||
import { eq } from "./drizzle";
|
||||
import { ErrorCodes, VisibleError } from "./error";
|
||||
import { createContext } from "./context";
|
||||
import { UserFlags, userTable } from "./user/user.sql";
|
||||
import { useTransaction } from "./drizzle/transaction";
|
||||
|
||||
export namespace Actor {
|
||||
export const PublicActor = z.object({
|
||||
type: z.literal("public"),
|
||||
properties: z.object({}),
|
||||
});
|
||||
export type PublicActor = z.infer<typeof PublicActor>;
|
||||
|
||||
export interface User {
|
||||
type: "user";
|
||||
properties: {
|
||||
userID: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
export const UserActor = z.object({
|
||||
type: z.literal("user"),
|
||||
properties: z.object({
|
||||
userID: z.string(),
|
||||
email: z.string().nonempty(),
|
||||
}),
|
||||
});
|
||||
export type UserActor = z.infer<typeof UserActor>;
|
||||
|
||||
export interface System {
|
||||
type: "system";
|
||||
properties: {
|
||||
teamID: string;
|
||||
};
|
||||
}
|
||||
export const MemberActor = z.object({
|
||||
type: z.literal("member"),
|
||||
properties: z.object({
|
||||
memberID: z.string(),
|
||||
teamID: z.string(),
|
||||
}),
|
||||
});
|
||||
export type MemberActor = z.infer<typeof MemberActor>;
|
||||
|
||||
export interface Machine {
|
||||
type: "machine";
|
||||
properties: {
|
||||
machineID: string;
|
||||
fingerprint: string;
|
||||
};
|
||||
}
|
||||
export const SystemActor = z.object({
|
||||
type: z.literal("system"),
|
||||
properties: z.object({
|
||||
teamID: z.string(),
|
||||
}),
|
||||
});
|
||||
export type SystemActor = z.infer<typeof SystemActor>;
|
||||
|
||||
export interface Token {
|
||||
type: "member";
|
||||
properties: {
|
||||
userID: string;
|
||||
steamID: string;
|
||||
teamID: string;
|
||||
};
|
||||
}
|
||||
export const MachineActor = z.object({
|
||||
type: z.literal("machine"),
|
||||
properties: z.object({
|
||||
fingerprint: z.string(),
|
||||
machineID: z.string(),
|
||||
}),
|
||||
});
|
||||
export type MachineActor = z.infer<typeof MachineActor>;
|
||||
|
||||
export interface Public {
|
||||
type: "public";
|
||||
properties: {};
|
||||
}
|
||||
export const Actor = z.discriminatedUnion("type", [
|
||||
MemberActor,
|
||||
UserActor,
|
||||
PublicActor,
|
||||
SystemActor,
|
||||
MachineActor
|
||||
]);
|
||||
export type Actor = z.infer<typeof Actor>;
|
||||
|
||||
export type Info = User | Public | Token | System | Machine;
|
||||
export const ActorContext = createContext<Actor>("actor");
|
||||
|
||||
export const Context = createContext<Info>();
|
||||
export const useActor = ActorContext.use;
|
||||
export const withActor = ActorContext.with;
|
||||
|
||||
export function userID() {
|
||||
const actor = Context.use();
|
||||
if ("userID" in actor.properties) return actor.properties.userID;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`You don't have permission to access this resource.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function steamID() {
|
||||
const actor = Context.use();
|
||||
if ("steamID" in actor.properties) return actor.properties.steamID;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`You don't have permission to access this resource.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function user() {
|
||||
const actor = Context.use();
|
||||
if (actor.type == "user") return actor.properties;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`You don't have permission to access this resource.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function teamID() {
|
||||
const actor = Context.use();
|
||||
if ("teamID" in actor.properties) return actor.properties.teamID;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`You don't have permission to access this resource.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fingerprint() {
|
||||
const actor = Context.use();
|
||||
if ("fingerprint" in actor.properties) return actor.properties.fingerprint;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`You don't have permission to access this resource.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function use() {
|
||||
try {
|
||||
return Context.use();
|
||||
} catch {
|
||||
return { type: "public", properties: {} } as Public;
|
||||
}
|
||||
}
|
||||
|
||||
export function assert<T extends Info["type"]>(type: T) {
|
||||
const actor = use();
|
||||
if (actor.type !== type)
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`Actor is not "${type}"`,
|
||||
);
|
||||
return actor as Extract<Info, { type: T }>;
|
||||
}
|
||||
|
||||
export function provide<
|
||||
T extends Info["type"],
|
||||
Next extends (...args: any) => any,
|
||||
>(type: T, properties: Extract<Info, { type: T }>["properties"], fn: Next) {
|
||||
return Context.provide({ type, properties } as any, () =>
|
||||
Log.provide(
|
||||
{
|
||||
actor: type,
|
||||
...properties,
|
||||
},
|
||||
fn,
|
||||
),
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Retrieves the user ID of the current actor.
|
||||
*
|
||||
* This function accesses the actor context and returns the `userID` if the current
|
||||
* actor is of type "user". If the actor is not a user, it throws a `VisibleError`
|
||||
* with an authentication error code, indicating that the caller is not authorized
|
||||
* to access user-specific resources.
|
||||
*
|
||||
* @throws {VisibleError} When the current actor is not of type "user".
|
||||
*/
|
||||
export function useUserID() {
|
||||
const actor = ActorContext.use();
|
||||
if (actor.type === "user") return actor.properties.userID;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`You don't have permission to access this resource`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the properties of the current user actor.
|
||||
*
|
||||
* This function obtains the current actor from the context and returns its properties if the actor is identified as a user.
|
||||
* If the actor is not of type "user", it throws a {@link VisibleError} with an authentication error code,
|
||||
* indicating that the user is not authorized to access user-specific resources.
|
||||
*
|
||||
* @returns The properties of the current user actor, typically including user-specific details such as userID and email.
|
||||
* @throws {VisibleError} If the current actor is not a user.
|
||||
*/
|
||||
export function useUser() {
|
||||
const actor = ActorContext.use();
|
||||
if (actor.type === "user") return actor.properties;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`You don't have permission to access this resource`,
|
||||
);
|
||||
}
|
||||
|
||||
export function assertActor<T extends Actor["type"]>(type: T) {
|
||||
const actor = useActor();
|
||||
if (actor.type !== type) {
|
||||
throw new Error(`Expected actor type ${type}, got ${actor.type}`);
|
||||
}
|
||||
|
||||
return actor as Extract<Actor, { type: T }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current actor's team ID.
|
||||
*
|
||||
* @returns The team ID associated with the current actor.
|
||||
* @throws {VisibleError} If the current actor does not have a {@link teamID} property.
|
||||
*/
|
||||
export function useTeam() {
|
||||
const actor = useActor();
|
||||
if ("teamID" in actor.properties) return actor.properties.teamID;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`Expected actor to have teamID`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the fingerprint of the current actor if the actor has a machine identity.
|
||||
*
|
||||
* @returns The fingerprint of the current machine actor.
|
||||
* @throws {VisibleError} If the current actor does not have a machine identity.
|
||||
*/
|
||||
export function useMachine() {
|
||||
const actor = useActor();
|
||||
if ("machineID" in actor.properties) return actor.properties.fingerprint;
|
||||
throw new VisibleError(
|
||||
"authentication",
|
||||
ErrorCodes.Authentication.UNAUTHORIZED,
|
||||
`Expected actor to have fingerprint`
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { timestamps, utc } from "../drizzle/types";
|
||||
import { json, numeric, pgEnum, pgTable, text, unique, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const CompatibilityEnum = pgEnum("compatibility", ["high", "mid", "low", "unknown"])
|
||||
export const ControllerEnum = pgEnum("controller_support", ["full","partial", "unknown"])
|
||||
|
||||
export const Size =
|
||||
z.object({
|
||||
downloadSize: z.number().positive().int(),
|
||||
sizeOnDisk: z.number().positive().int()
|
||||
})
|
||||
|
||||
export type Size = z.infer<typeof Size>
|
||||
|
||||
export const baseGamesTable = pgTable(
|
||||
"base_games",
|
||||
{
|
||||
...timestamps,
|
||||
id: varchar("id", { length: 255 })
|
||||
.primaryKey()
|
||||
.notNull(),
|
||||
slug: varchar("slug", { length: 255 })
|
||||
.notNull(),
|
||||
name: text("name").notNull(),
|
||||
releaseDate: utc("release_date").notNull(),
|
||||
size: json("size").$type<Size>().notNull(),
|
||||
description: text("description").notNull(),
|
||||
primaryGenre: text("primary_genre"),
|
||||
controllerSupport: ControllerEnum("controller_support").notNull(),
|
||||
compatibility: CompatibilityEnum("compatibility").notNull().default("unknown"),
|
||||
// Score ranges from 0.0 to 5.0
|
||||
score: numeric("score", { precision: 2, scale: 1 })
|
||||
.$type<number>()
|
||||
.notNull()
|
||||
},
|
||||
(table) => [
|
||||
unique("idx_base_games_slug").on(table.slug),
|
||||
]
|
||||
)
|
||||
@@ -1,127 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Resource } from "sst";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { Common } from "../common";
|
||||
import { Examples } from "../examples";
|
||||
import { createEvent } from "../event";
|
||||
import { eq, isNull, and } from "drizzle-orm";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { CompatibilityEnum, baseGamesTable, Size, ControllerEnum } from "./base-game.sql";
|
||||
|
||||
export namespace BaseGame {
|
||||
export const Info = z.object({
|
||||
id: z.string().openapi({
|
||||
description: Common.IdDescription,
|
||||
example: Examples.BaseGame.id
|
||||
}),
|
||||
slug: z.string().openapi({
|
||||
description: "A URL-friendly unique identifier for the game, used in web addresses and API endpoints",
|
||||
example: Examples.BaseGame.slug
|
||||
}),
|
||||
name: z.string().openapi({
|
||||
description: "The official title of the game as listed on Steam",
|
||||
example: Examples.BaseGame.name
|
||||
}),
|
||||
size: Size.openapi({
|
||||
description: "Storage requirements in bytes: downloadSize represents the compressed download, and sizeOnDisk represents the installed size",
|
||||
example: Examples.BaseGame.size
|
||||
}),
|
||||
releaseDate: z.date().openapi({
|
||||
description: "The initial public release date of the game on Steam",
|
||||
example: Examples.BaseGame.releaseDate
|
||||
}),
|
||||
description: z.string().openapi({
|
||||
description: "A comprehensive overview of the game, including its features, storyline, and gameplay elements",
|
||||
example: Examples.BaseGame.description
|
||||
}),
|
||||
score: z.number().openapi({
|
||||
description: "The aggregate user review score on Steam, represented as a percentage of positive reviews",
|
||||
example: Examples.BaseGame.score
|
||||
}),
|
||||
primaryGenre: z.string().nullable().openapi({
|
||||
description: "The main category or genre that best represents the game's content and gameplay style",
|
||||
example: Examples.BaseGame.primaryGenre
|
||||
}),
|
||||
controllerSupport: z.enum(ControllerEnum.enumValues).openapi({
|
||||
description: "Indicates the level of gamepad/controller compatibility: 'Full', 'Partial', or 'Unkown' for no support",
|
||||
example: Examples.BaseGame.controllerSupport
|
||||
}),
|
||||
compatibility: z.enum(CompatibilityEnum.enumValues).openapi({
|
||||
description: "Steam Deck/Proton compatibility rating indicating how well the game runs on Linux systems",
|
||||
example: Examples.BaseGame.compatibility
|
||||
})
|
||||
}).openapi({
|
||||
ref: "BaseGame",
|
||||
description: "Detailed information about a game available in the Nestri library, including technical specifications and metadata",
|
||||
example: Examples.BaseGame
|
||||
})
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Events = {
|
||||
New: createEvent(
|
||||
"new_game.added",
|
||||
z.object({
|
||||
appID: Info.shape.id,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const create = fn(
|
||||
Info,
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
await tx
|
||||
.insert(baseGamesTable)
|
||||
.values(input)
|
||||
.onConflictDoUpdate({
|
||||
target: baseGamesTable.id,
|
||||
set: {
|
||||
timeDeleted: null
|
||||
}
|
||||
})
|
||||
|
||||
await afterTx(async () => {
|
||||
await bus.publish(Resource.Bus, Events.New, { appID: input.id })
|
||||
})
|
||||
|
||||
return input.id
|
||||
})
|
||||
)
|
||||
|
||||
export const fromID = fn(
|
||||
Info.shape.id,
|
||||
(id) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(baseGamesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(baseGamesTable.id, id),
|
||||
isNull(baseGamesTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.then(rows => rows.map(serialize).at(0))
|
||||
)
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: typeof baseGamesTable.$inferSelect,
|
||||
): z.infer<typeof Info> {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
size: input.size,
|
||||
score: input.score,
|
||||
description: input.description,
|
||||
releaseDate: input.releaseDate,
|
||||
primaryGenre: input.primaryGenre,
|
||||
compatibility: input.compatibility,
|
||||
controllerSupport: input.controllerSupport,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { timestamps } from "../drizzle/types";
|
||||
import { index, pgEnum, pgTable, primaryKey, text, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const CategoryTypeEnum = pgEnum("category_type", ["tag", "genre", "publisher", "developer"])
|
||||
|
||||
export const categoriesTable = pgTable(
|
||||
"categories",
|
||||
{
|
||||
...timestamps,
|
||||
slug: varchar("slug", { length: 255 })
|
||||
.notNull(),
|
||||
type: CategoryTypeEnum("type").notNull(),
|
||||
name: text("name").notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.slug, table.type]
|
||||
}),
|
||||
index("idx_categories_type").on(table.type),
|
||||
]
|
||||
)
|
||||
@@ -1,117 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Examples } from "../examples";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { categoriesTable } from "./categories.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
import { eq, isNull, and } from "drizzle-orm";
|
||||
|
||||
export namespace Categories {
|
||||
|
||||
const Category = z.object({
|
||||
slug: z.string().openapi({
|
||||
description: "A URL-friendly unique identifier for the category",
|
||||
example: "action-adventure"
|
||||
}),
|
||||
name: z.string().openapi({
|
||||
description: "The human-readable display name of the category",
|
||||
example: "Action Adventure"
|
||||
})
|
||||
})
|
||||
|
||||
export const Info =
|
||||
z.object({
|
||||
publishers: Category.array().openapi({
|
||||
description: "List of companies or organizations responsible for publishing and distributing the game",
|
||||
example: Examples.Categories.publishers
|
||||
}),
|
||||
developers: Category.array().openapi({
|
||||
description: "List of studios, teams, or individuals who created and developed the game",
|
||||
example: Examples.Categories.developers
|
||||
}),
|
||||
tags: Category.array().openapi({
|
||||
description: "User-defined labels that describe specific features, themes, or characteristics of the game",
|
||||
example: Examples.Categories.tags
|
||||
}),
|
||||
genres: Category.array().openapi({
|
||||
description: "Primary classification categories that define the game's style and type of gameplay",
|
||||
example: Examples.Categories.genres
|
||||
})
|
||||
}).openapi({
|
||||
ref: "Categories",
|
||||
description: "A comprehensive categorization system for games, including publishing details, development credits, and content classification",
|
||||
example: Examples.Categories
|
||||
})
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const InputInfo = createSelectSchema(categoriesTable)
|
||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
||||
|
||||
export const create = fn(
|
||||
InputInfo,
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const result = await tx
|
||||
.select()
|
||||
.from(categoriesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(categoriesTable.slug, input.slug),
|
||||
eq(categoriesTable.type, input.type),
|
||||
isNull(categoriesTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then(rows => rows.at(0))
|
||||
|
||||
if (result) return result.slug
|
||||
|
||||
await tx
|
||||
.insert(categoriesTable)
|
||||
.values(input)
|
||||
.onConflictDoUpdate({
|
||||
target: [categoriesTable.slug, categoriesTable.type],
|
||||
set: { timeDeleted: null }
|
||||
})
|
||||
|
||||
return input.slug
|
||||
})
|
||||
)
|
||||
|
||||
export const get = fn(
|
||||
InputInfo.pick({ slug: true, type: true }),
|
||||
(input) =>
|
||||
useTransaction((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(categoriesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(categoriesTable.slug, input.slug),
|
||||
eq(categoriesTable.type, input.type),
|
||||
isNull(categoriesTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then(rows => serialize(rows))
|
||||
)
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: typeof categoriesTable.$inferSelect[],
|
||||
): z.infer<typeof Info> {
|
||||
return input.reduce<Record<`${typeof categoriesTable.$inferSelect["type"]}s`, { slug: string; name: string }[]>>((acc, cat) => {
|
||||
const key = `${cat.type}s` as `${typeof cat.type}s`
|
||||
acc[key]!.push({ slug: cat.slug, name: cat.name })
|
||||
return acc
|
||||
}, {
|
||||
tags: [],
|
||||
genres: [],
|
||||
publishers: [],
|
||||
developers: []
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import type {
|
||||
AppInfo,
|
||||
GameTagsResponse,
|
||||
SteamApiResponse,
|
||||
GameDetailsResponse,
|
||||
SteamAppDataResponse,
|
||||
ImageInfo,
|
||||
ImageType,
|
||||
Shot
|
||||
} from "./types";
|
||||
import { z } from "zod";
|
||||
import pLimit from 'p-limit';
|
||||
import SteamID from "steamid";
|
||||
import { fn } from "../utils";
|
||||
import { Utils } from "./utils";
|
||||
import SteamCommunity from "steamcommunity";
|
||||
import { Credentials } from "../credentials";
|
||||
import type CSteamUser from "steamcommunity/classes/CSteamUser";
|
||||
|
||||
const requestLimit = pLimit(10); // max concurrent requests
|
||||
|
||||
export namespace Client {
|
||||
export const getUserLibrary = fn(
|
||||
Credentials.Info.shape.accessToken,
|
||||
async (accessToken) =>
|
||||
await Utils.fetchApi<SteamApiResponse>(`https://api.steampowered.com/IFamilyGroupsService/GetSharedLibraryApps/v1/?access_token=${accessToken}&family_groupid=0&include_excluded=true&include_free=true&include_non_games=false&include_own=true`)
|
||||
)
|
||||
|
||||
export const getFriendsList = fn(
|
||||
Credentials.Info.shape.cookies,
|
||||
async (cookies): Promise<CSteamUser[]> => {
|
||||
const community = new SteamCommunity();
|
||||
community.setCookies(cookies);
|
||||
|
||||
const allFriends = await new Promise<Record<string, any>>((resolve, reject) => {
|
||||
community.getFriendsList((err, friends) => {
|
||||
if (err) {
|
||||
return reject(new Error(`Could not get friends list: ${err.message}`));
|
||||
}
|
||||
resolve(friends);
|
||||
});
|
||||
});
|
||||
|
||||
const friendIds = Object.keys(allFriends);
|
||||
|
||||
const userPromises: Promise<CSteamUser>[] = friendIds.map(id =>
|
||||
requestLimit(() => new Promise<CSteamUser>((resolve, reject) => {
|
||||
const sid = new SteamID(id);
|
||||
community.getSteamUser(sid, (err, user) => {
|
||||
if (err) {
|
||||
return reject(new Error(`Could not get steam user info for ${id}: ${err.message}`));
|
||||
}
|
||||
resolve(user);
|
||||
});
|
||||
}))
|
||||
);
|
||||
|
||||
const settled = await Promise.allSettled(userPromises)
|
||||
|
||||
settled
|
||||
.filter(r => r.status === "rejected")
|
||||
.forEach(r => console.warn("[getFriendsList] failed:", (r as PromiseRejectedResult).reason));
|
||||
|
||||
return settled.filter(s => s.status === "fulfilled").map(r => (r as PromiseFulfilledResult<CSteamUser>).value);
|
||||
}
|
||||
);
|
||||
|
||||
export const getUserInfo = fn(
|
||||
Credentials.Info.pick({ cookies: true, steamID: true }),
|
||||
async (input) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const community = new SteamCommunity()
|
||||
community.setCookies(input.cookies);
|
||||
const steamID = new SteamID(input.steamID);
|
||||
community.getSteamUser(steamID, async (err, user) => {
|
||||
if (err) {
|
||||
reject(`Could not get steam user info: ${err.message}`)
|
||||
} else {
|
||||
resolve(user)
|
||||
}
|
||||
})
|
||||
}) as Promise<CSteamUser>
|
||||
)
|
||||
|
||||
export const getAppInfo = fn(
|
||||
z.string(),
|
||||
async (appid) => {
|
||||
const [infoData, tagsData, details] = await Promise.all([
|
||||
Utils.fetchApi<SteamAppDataResponse>(`https://api.steamcmd.net/v1/info/${appid}`),
|
||||
Utils.fetchApi<GameTagsResponse>("https://store.steampowered.com/actions/ajaxgetstoretags"),
|
||||
Utils.fetchApi<GameDetailsResponse>(
|
||||
`https://store.steampowered.com/apphover/${appid}?full=1&review_score_preference=1&pagev6=true&json=1`
|
||||
),
|
||||
]);
|
||||
|
||||
const tags = tagsData.tags;
|
||||
const game = infoData.data[appid];
|
||||
// Guard against an empty string - When there are no genres, Steam returns an empty string
|
||||
const genres = details.strGenres ? Utils.parseGenres(details.strGenres) : [];
|
||||
|
||||
const controllerTag = game.common.controller_support ?
|
||||
Utils.createTag(`${Utils.capitalise(game.common.controller_support)} Controller Support`) :
|
||||
Utils.createTag(`Unknown Controller Support`)
|
||||
|
||||
const compatibilityTag = Utils.createTag(`${Utils.capitalise(Utils.compatibilityType(game.common.steam_deck_compatibility?.category))} Compatibility`)
|
||||
|
||||
const controller = (game.common.controller_support === "partial" || game.common.controller_support === "full") ? game.common.controller_support : "unknown";
|
||||
const appInfo: AppInfo = {
|
||||
genres,
|
||||
gameid: game.appid,
|
||||
name: game.common.name.trim(),
|
||||
size: Utils.getPublicDepotSizes(game.depots!),
|
||||
slug: Utils.createSlug(game.common.name.trim()),
|
||||
description: Utils.cleanDescription(details.strDescription),
|
||||
controllerSupport: controller,
|
||||
releaseDate: new Date(Number(game.common.steam_release_date) * 1000),
|
||||
primaryGenre: (!!game?.common.genres && !!details.strGenres) ? Utils.getPrimaryGenre(
|
||||
genres,
|
||||
game.common.genres!,
|
||||
game.common.primary_genre!
|
||||
) : null,
|
||||
developers: game.common.associations ?
|
||||
Array.from(
|
||||
Utils.getAssociationsByTypeWithSlug(
|
||||
game.common.associations!,
|
||||
"developer"
|
||||
)
|
||||
) : [],
|
||||
publishers: game.common.associations ?
|
||||
Array.from(
|
||||
Utils.getAssociationsByTypeWithSlug(
|
||||
game.common.associations!,
|
||||
"publisher"
|
||||
)
|
||||
) : [],
|
||||
compatibility: Utils.compatibilityType(game.common.steam_deck_compatibility?.category),
|
||||
tags: [
|
||||
...(game?.common.store_tags ?
|
||||
Utils.mapGameTags(
|
||||
tags,
|
||||
game.common.store_tags!,
|
||||
) : []),
|
||||
controllerTag,
|
||||
compatibilityTag
|
||||
],
|
||||
score: Utils.getRating(
|
||||
details.ReviewSummary.cRecommendationsPositive,
|
||||
details.ReviewSummary.cRecommendationsNegative
|
||||
),
|
||||
};
|
||||
|
||||
return appInfo
|
||||
}
|
||||
)
|
||||
|
||||
export const getImages = fn(
|
||||
z.string(),
|
||||
async (appid) => {
|
||||
const [appData, details] = await Promise.all([
|
||||
Utils.fetchApi<SteamAppDataResponse>(`https://api.steamcmd.net/v1/info/${appid}`),
|
||||
Utils.fetchApi<GameDetailsResponse>(
|
||||
`https://store.steampowered.com/apphover/${appid}?full=1&review_score_preference=1&pagev6=true&json=1`
|
||||
),
|
||||
]);
|
||||
|
||||
const game = appData.data[appid]?.common;
|
||||
if (!game) throw new Error('Game info missing');
|
||||
|
||||
// 2. Prepare URLs
|
||||
const screenshotUrls = Utils.getScreenshotUrls(details.rgScreenshots || []);
|
||||
const assetUrls = Utils.getAssetUrls(game.library_assets_full, appid, game.header_image.english);
|
||||
const iconUrl = `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${appid}/${game.icon}.jpg`;
|
||||
|
||||
//2.5 Get the backdrop buffer and use it to get the best screenshot
|
||||
const baselineBuffer = await Utils.fetchBuffer(assetUrls.backdrop);
|
||||
|
||||
// 3. Download screenshot buffers in parallel
|
||||
const shots: Shot[] = await Promise.all(
|
||||
screenshotUrls.map(async url => ({ url, buffer: await Utils.fetchBuffer(url) }))
|
||||
);
|
||||
|
||||
// 4. Score screenshots (or pick single)
|
||||
const scores =
|
||||
shots.length === 1
|
||||
? [{ url: shots[0].url, score: 0 }]
|
||||
: (await Utils.rankScreenshots(baselineBuffer, shots, {
|
||||
threshold: 0.08,
|
||||
}))
|
||||
|
||||
// Build url->rank map
|
||||
const rankMap = new Map<string, number>();
|
||||
scores.forEach((s, i) => rankMap.set(s.url, i));
|
||||
|
||||
// 5. Create tasks for all images
|
||||
const tasks: Array<Promise<ImageInfo>> = [];
|
||||
|
||||
// 5a. Screenshots and heroArt metadata (top 4)
|
||||
for (const { url, buffer } of shots) {
|
||||
const rank = rankMap.get(url);
|
||||
if (rank === undefined || rank >= 4) continue;
|
||||
const type: ImageType = rank === 0 ? 'heroArt' : 'screenshot';
|
||||
tasks.push(
|
||||
Utils.getImageMetadata(buffer).then(meta => ({ ...meta, sourceUrl: url, position: type == "screenshot" ? rank - 1 : rank, type } as ImageInfo))
|
||||
);
|
||||
}
|
||||
|
||||
// 5b. Asset images
|
||||
for (const [type, url] of Object.entries({ ...assetUrls, icon: iconUrl })) {
|
||||
if (!url || type === "backdrop") continue;
|
||||
tasks.push(
|
||||
Utils.fetchBuffer(url)
|
||||
.then(buf => Utils.getImageMetadata(buf))
|
||||
.then(meta => ({ ...meta, position: 0, sourceUrl: url, type: type as ImageType } as ImageInfo))
|
||||
);
|
||||
}
|
||||
|
||||
// 5c. Backdrop
|
||||
tasks.push(
|
||||
Utils.getImageMetadata(baselineBuffer)
|
||||
.then(meta => ({ ...meta, position: 0, sourceUrl: assetUrls.backdrop, type: "backdrop" as const } as ImageInfo))
|
||||
)
|
||||
|
||||
// 5d. Box art
|
||||
tasks.push(
|
||||
Utils.createBoxArtBuffer(game.library_assets_full, appid)
|
||||
.then(buf => Utils.getImageMetadata(buf))
|
||||
.then(meta => ({ ...meta, position: 0, sourceUrl: null, type: 'boxArt' as const }) as ImageInfo)
|
||||
);
|
||||
|
||||
const settled = await Promise.allSettled(tasks)
|
||||
|
||||
settled
|
||||
.filter(r => r.status === "rejected")
|
||||
.forEach(r => console.warn("[getImages] failed:", (r as PromiseRejectedResult).reason));
|
||||
|
||||
// 6. Await all and return
|
||||
return settled.filter(s => s.status === "fulfilled").map(r => (r as PromiseFulfilledResult<ImageInfo>).value)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
export interface SteamApp {
|
||||
/** Steam application ID */
|
||||
appid: number;
|
||||
|
||||
/** Array of Steam IDs that own this app */
|
||||
owner_steamids: string[];
|
||||
|
||||
/** Name of the game/application */
|
||||
name: string;
|
||||
|
||||
/** Filename of the game's capsule image */
|
||||
capsule_filename: string;
|
||||
|
||||
/** Hash value for the game's icon */
|
||||
img_icon_hash: string;
|
||||
|
||||
/** Reason code for exclusion (0 indicates no exclusion) */
|
||||
exclude_reason: number;
|
||||
|
||||
/** Unix timestamp when the app was acquired */
|
||||
rt_time_acquired: number;
|
||||
|
||||
/** Unix timestamp when the app was last played */
|
||||
rt_last_played: number;
|
||||
|
||||
/** Total playtime in seconds */
|
||||
rt_playtime: number;
|
||||
|
||||
/** Type identifier for the app (1 = game) */
|
||||
app_type: number;
|
||||
|
||||
/** Array of content descriptor IDs */
|
||||
content_descriptors?: number[];
|
||||
}
|
||||
|
||||
export interface SteamApiResponse {
|
||||
response: {
|
||||
apps: SteamApp[];
|
||||
owner_steamid: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SteamAppDataResponse {
|
||||
data: Record<string, SteamAppEntry>;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface SteamAppEntry {
|
||||
_change_number: number;
|
||||
_missing_token: boolean;
|
||||
_sha: string;
|
||||
_size: number;
|
||||
appid: string;
|
||||
common: CommonData;
|
||||
config: AppConfig;
|
||||
depots: AppDepots;
|
||||
extended: AppExtended;
|
||||
ufs: UFSData;
|
||||
}
|
||||
|
||||
export interface CommonData {
|
||||
associations: Record<string, { name: string; type: string }>;
|
||||
category: Record<string, string>;
|
||||
clienticon: string;
|
||||
clienttga: string;
|
||||
community_hub_visible: string;
|
||||
community_visible_stats: string;
|
||||
content_descriptors: Record<string, string>;
|
||||
controller_support?: string;
|
||||
controllertagwizard: string;
|
||||
gameid: string;
|
||||
genres: Record<string, string>;
|
||||
header_image: Record<string, string>;
|
||||
icon: string;
|
||||
languages: Record<string, string>;
|
||||
library_assets: LibraryAssets;
|
||||
library_assets_full: LibraryAssetsFull;
|
||||
metacritic_fullurl: string;
|
||||
metacritic_name: string;
|
||||
metacritic_score: string;
|
||||
name: string;
|
||||
name_localized: Partial<Record<LanguageCode, string>>;
|
||||
osarch: string;
|
||||
osextended: string;
|
||||
oslist: string;
|
||||
primary_genre: string;
|
||||
releasestate: string;
|
||||
review_percentage: string;
|
||||
review_score: string;
|
||||
small_capsule: Record<string, string>;
|
||||
steam_deck_compatibility: SteamDeckCompatibility;
|
||||
steam_release_date: string;
|
||||
store_asset_mtime: string;
|
||||
store_tags: Record<string, string>;
|
||||
supported_languages: Record<
|
||||
string,
|
||||
{
|
||||
full_audio?: string;
|
||||
subtitles?: string;
|
||||
supported?: string;
|
||||
}
|
||||
>;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface LibraryAssets {
|
||||
library_capsule: string;
|
||||
library_header: string;
|
||||
library_hero: string;
|
||||
library_logo: string;
|
||||
logo_position: LogoPosition;
|
||||
}
|
||||
|
||||
export interface LogoPosition {
|
||||
height_pct: string;
|
||||
pinned_position: string;
|
||||
width_pct: string;
|
||||
}
|
||||
|
||||
export interface LibraryAssetsFull {
|
||||
library_capsule: ImageSet;
|
||||
library_header: ImageSet;
|
||||
library_hero: ImageSet;
|
||||
library_logo: ImageSet & { logo_position: LogoPosition };
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface ImageSet {
|
||||
image: Record<string, string>;
|
||||
image2x?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SteamDeckCompatibility {
|
||||
category: string;
|
||||
configuration: Record<string, string>;
|
||||
test_timestamp: string;
|
||||
tested_build_id: string;
|
||||
tests: Record<string, { display: string; token: string }>;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
installdir: string;
|
||||
launch: Record<
|
||||
string,
|
||||
{
|
||||
executable: string;
|
||||
type: string;
|
||||
arguments?: string;
|
||||
description?: string;
|
||||
description_loc?: Record<string, string>;
|
||||
config?: {
|
||||
betakey: string;
|
||||
};
|
||||
}
|
||||
>;
|
||||
steamcontrollertemplateindex: string;
|
||||
steamdecktouchscreen: string;
|
||||
}
|
||||
|
||||
export interface AppDepots {
|
||||
branches: AppDepotBranches;
|
||||
privatebranches: Record<string, AppDepotBranches>;
|
||||
[depotId: string]: DepotEntry
|
||||
| AppDepotBranches
|
||||
| Record<string, AppDepotBranches>;
|
||||
}
|
||||
|
||||
|
||||
export interface DepotEntry {
|
||||
manifests: {
|
||||
public: {
|
||||
download: string;
|
||||
gid: string;
|
||||
size: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppDepotBranches {
|
||||
[branchName: string]: {
|
||||
buildid: string;
|
||||
timeupdated: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppExtended {
|
||||
additional_dependencies: Array<{
|
||||
dest_os: string;
|
||||
h264: string;
|
||||
src_os: string;
|
||||
}>;
|
||||
developer: string;
|
||||
dlcavailableonstore: string;
|
||||
homepage: string;
|
||||
listofdlc: string;
|
||||
publisher: string;
|
||||
}
|
||||
|
||||
export interface UFSData {
|
||||
maxnumfiles: string;
|
||||
quota: string;
|
||||
savefiles: Array<{
|
||||
path: string;
|
||||
pattern: string;
|
||||
recursive: string;
|
||||
root: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type LanguageCode =
|
||||
| "english"
|
||||
| "french"
|
||||
| "german"
|
||||
| "italian"
|
||||
| "japanese"
|
||||
| "koreana"
|
||||
| "polish"
|
||||
| "russian"
|
||||
| "schinese"
|
||||
| "tchinese"
|
||||
| "brazilian"
|
||||
| "spanish";
|
||||
|
||||
export interface Screenshot {
|
||||
appid: number;
|
||||
id: number;
|
||||
filename: string;
|
||||
all_ages: string;
|
||||
normalized_name: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
strDisplayName: string;
|
||||
}
|
||||
|
||||
export interface ReviewSummary {
|
||||
strReviewSummary: string;
|
||||
cReviews: number;
|
||||
cRecommendationsPositive: number;
|
||||
cRecommendationsNegative: number;
|
||||
nReviewScore: number;
|
||||
}
|
||||
|
||||
export interface GameDetailsResponse {
|
||||
strReleaseDate: string;
|
||||
strDescription: string;
|
||||
rgScreenshots: Screenshot[];
|
||||
rgCategories: Category[];
|
||||
strGenres?: string;
|
||||
strFullDescription: string;
|
||||
strMicroTrailerURL: string;
|
||||
ReviewSummary: ReviewSummary;
|
||||
}
|
||||
|
||||
// Define the TypeScript interfaces
|
||||
export interface Tag {
|
||||
tagid: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TagWithSlug {
|
||||
name: string;
|
||||
slug: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface StoreTags {
|
||||
[key: string]: string; // Index signature for numeric string keys to tag ID strings
|
||||
}
|
||||
|
||||
|
||||
export interface GameTagsResponse {
|
||||
tags: Tag[];
|
||||
success: number;
|
||||
rwgrsn: number;
|
||||
}
|
||||
|
||||
export type GenreType = {
|
||||
type: 'genre';
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
export interface AppInfo {
|
||||
name: string;
|
||||
slug: string;
|
||||
score: number;
|
||||
gameid: string;
|
||||
releaseDate: Date;
|
||||
description: string;
|
||||
compatibility: "low" | "mid" | "high" | "unknown";
|
||||
controllerSupport: "partial" | "full" | "unknown";
|
||||
primaryGenre: string | null;
|
||||
size: { downloadSize: number; sizeOnDisk: number };
|
||||
tags: Array<{ name: string; slug: string; type: "tag" }>;
|
||||
genres: Array<{ type: "genre"; name: string; slug: string }>;
|
||||
developers: Array<{ name: string; slug: string; type: "developer" }>;
|
||||
publishers: Array<{ name: string; slug: string; type: "publisher" }>;
|
||||
}
|
||||
|
||||
export type ImageType =
|
||||
| 'screenshot'
|
||||
| 'boxArt'
|
||||
| 'banner'
|
||||
| 'backdrop'
|
||||
| 'icon'
|
||||
| 'logo'
|
||||
| 'poster'
|
||||
| 'heroArt';
|
||||
|
||||
export interface ImageInfo {
|
||||
type: ImageType;
|
||||
position: number;
|
||||
hash: string;
|
||||
sourceUrl: string | null;
|
||||
format?: string;
|
||||
averageColor: { hex: string; isDark: boolean };
|
||||
dimensions: { width: number; height: number };
|
||||
fileSize: number;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export interface CompareOpts {
|
||||
/** Pixelmatch color threshold (0–1). Default: 0.1 */
|
||||
threshold?: number;
|
||||
/** If true, return an image buffer of the diff map. Default: false */
|
||||
diffOutput?: boolean;
|
||||
}
|
||||
|
||||
export interface CompareResult {
|
||||
diffRatio: number;
|
||||
/** Present only if `diffOutput: true` */
|
||||
diffBuffer?: Buffer;
|
||||
}
|
||||
|
||||
export interface Shot {
|
||||
url: string;
|
||||
buffer: Buffer;
|
||||
}
|
||||
|
||||
export interface RankedShot {
|
||||
url: string;
|
||||
score: number;
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
import type {
|
||||
Tag,
|
||||
StoreTags,
|
||||
AppDepots,
|
||||
GenreType,
|
||||
LibraryAssetsFull,
|
||||
DepotEntry,
|
||||
CompareOpts,
|
||||
CompareResult,
|
||||
RankedShot,
|
||||
Shot,
|
||||
} from "./types";
|
||||
import crypto from 'crypto';
|
||||
import pLimit from 'p-limit';
|
||||
import { PNG } from 'pngjs';
|
||||
import pixelmatch from 'pixelmatch';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import { Agent as HttpAgent } from 'http';
|
||||
import { Agent as HttpsAgent } from 'https';
|
||||
import sharp, { type Metadata } from 'sharp';
|
||||
import AbortController from 'abort-controller';
|
||||
import fetch, { RequestInit } from 'node-fetch';
|
||||
import { FastAverageColor } from 'fast-average-color';
|
||||
|
||||
const fac = new FastAverageColor()
|
||||
// --- Configuration ---
|
||||
const httpAgent = new HttpAgent({ keepAlive: true, maxSockets: 50 });
|
||||
const httpsAgent = new HttpsAgent({ keepAlive: true, maxSockets: 50 });
|
||||
const downloadCache = new LRUCache<string, Buffer>({
|
||||
max: 100,
|
||||
ttl: 1000 * 60 * 30, // 30-minute expiry
|
||||
allowStale: false,
|
||||
});
|
||||
const downloadLimit = pLimit(10); // max concurrent downloads
|
||||
const compareCache = new LRUCache<string, CompareResult>({
|
||||
max: 50,
|
||||
ttl: 1000 * 60 * 10, // 10-minute expiry
|
||||
});
|
||||
|
||||
export namespace Utils {
|
||||
export async function fetchBuffer(url: string, retries = 3): Promise<Buffer> {
|
||||
if (downloadCache.has(url)) {
|
||||
return downloadCache.get(url)!;
|
||||
}
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const id = setTimeout(() => controller.abort(), 15_000);
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
agent: (_parsed) => _parsed.protocol === 'http:' ? httpAgent : httpsAgent
|
||||
} as RequestInit);
|
||||
clearTimeout(id);
|
||||
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
downloadCache.set(url, buf);
|
||||
return buf;
|
||||
} catch (error: any) {
|
||||
lastError = error as Error;
|
||||
console.warn(`Attempt ${attempt + 1} failed for ${url}: ${error.message}`);
|
||||
if (attempt < retries - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error(`Failed to fetch ${url} after ${retries} attempts`);
|
||||
}
|
||||
|
||||
export async function getImageMetadata(buffer: Buffer) {
|
||||
const hash = crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
const { width, height, format, size: fileSize } = await sharp(buffer).metadata();
|
||||
if (!width || !height) throw new Error('Invalid dimensions');
|
||||
|
||||
const slice = await sharp(buffer)
|
||||
.resize({ width: Math.min(width, 256) }) // cheap shrink
|
||||
.ensureAlpha()
|
||||
.raw()
|
||||
.toBuffer();
|
||||
|
||||
const pixelArray = new Uint8Array(slice.buffer);
|
||||
const { hex, isDark } = fac.prepareResult(fac.getColorFromArray4(pixelArray, { mode: "precision" }));
|
||||
|
||||
return { hash, format, averageColor: { hex, isDark }, dimensions: { width, height }, fileSize, buffer };
|
||||
}
|
||||
|
||||
// --- Optimized Box Art creation ---
|
||||
export async function createBoxArtBuffer(
|
||||
assets: LibraryAssetsFull,
|
||||
appid: number | string,
|
||||
logoPercent = 0.9
|
||||
): Promise<Buffer> {
|
||||
const base = `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${appid}`;
|
||||
const pick = (key: string) => {
|
||||
const set = assets[key];
|
||||
const path = set?.image2x?.english || set?.image?.english;
|
||||
if (!path) throw new Error(`Missing asset for ${key}`);
|
||||
return `${base}/${path}`;
|
||||
};
|
||||
|
||||
const [bgBuf, logoBuf] = await Promise.all([
|
||||
downloadLimit(() =>
|
||||
fetchBuffer(pick('library_hero'))
|
||||
.catch(error => {
|
||||
console.error(`Failed to download hero image for ${appid}:`, error);
|
||||
throw new Error(`Failed to create box art: hero image unavailable`);
|
||||
}),
|
||||
),
|
||||
downloadLimit(() => fetchBuffer(pick('library_logo'))
|
||||
.catch(error => {
|
||||
console.error(`Failed to download logo image for ${appid}:`, error);
|
||||
throw new Error(`Failed to create box art: logo image unavailable`);
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
const bgImage = sharp(bgBuf);
|
||||
const meta = await bgImage.metadata();
|
||||
if (!meta.width || !meta.height) throw new Error('Invalid background dimensions');
|
||||
const size = Math.min(meta.width, meta.height);
|
||||
const left = Math.floor((meta.width - size) / 2);
|
||||
const top = Math.floor((meta.height - size) / 2);
|
||||
const squareBg = bgImage.extract({ left, top, width: size, height: size });
|
||||
|
||||
// Resize logo
|
||||
const logoTarget = Math.floor(size * logoPercent);
|
||||
const logoResized = await sharp(logoBuf).resize({ width: logoTarget }).toBuffer();
|
||||
const logoMeta = await sharp(logoResized).metadata();
|
||||
if (!logoMeta.width || !logoMeta.height) throw new Error('Invalid logo dimensions');
|
||||
const logoLeft = Math.floor((size - logoMeta.width) / 2);
|
||||
const logoTop = Math.floor((size - logoMeta.height) / 2);
|
||||
|
||||
return await squareBg
|
||||
.composite([{ input: logoResized, left: logoLeft, top: logoTop }])
|
||||
.jpeg({ quality: 100 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch JSON from the given URL, with Steam-like headers
|
||||
*/
|
||||
export async function fetchApi<T>(url: string, retries = 3): Promise<T> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
agent: (_parsed) => _parsed.protocol === 'http:' ? httpAgent : httpsAgent,
|
||||
method: "GET",
|
||||
headers: {
|
||||
"User-Agent": "Steam 1291812 / iPhone",
|
||||
"Accept-Language": "en-us",
|
||||
},
|
||||
} as RequestInit);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
} catch (error: any) {
|
||||
lastError = error as Error;
|
||||
// Only retry on network errors or 5xx status codes
|
||||
if (error.message.includes('API error: 5') || !error.message.includes('API error')) {
|
||||
console.warn(`Attempt ${attempt + 1} failed for ${url}: ${error.message}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, attempt)));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error(`Failed to fetch ${url} after ${retries} attempts`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a slug from a name
|
||||
*/
|
||||
export function createSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s -]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a candidate screenshot against a UI-free baseline to find how much UI/HUD remains.
|
||||
*
|
||||
* @param baselineBuffer - PNG/JPEG buffer of the clean background.
|
||||
* @param candidateBuffer - PNG/JPEG buffer of the screenshot to test.
|
||||
* @param opts - Options.
|
||||
* @returns Promise resolving to diff ratio (and optional diff image).
|
||||
*/
|
||||
export async function compareWithBaseline(
|
||||
baselineBuffer: Buffer,
|
||||
candidateBuffer: Buffer,
|
||||
opts: CompareOpts = {}
|
||||
): Promise<CompareResult> {
|
||||
// Generate cache key from buffer hashes
|
||||
const baseHash = crypto.createHash('md5').update(baselineBuffer).digest('hex');
|
||||
const candHash = crypto.createHash('md5').update(candidateBuffer).digest('hex');
|
||||
const optsKey = JSON.stringify(opts);
|
||||
const cacheKey = `${baseHash}:${candHash}:${optsKey}`;
|
||||
|
||||
// Check cache
|
||||
if (compareCache.has(cacheKey)) {
|
||||
return compareCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
const { threshold = 0.1, diffOutput = false } = opts;
|
||||
|
||||
// Get dimensions of baseline
|
||||
const baseMeta: Metadata = await sharp(baselineBuffer).metadata();
|
||||
if (!baseMeta.width || !baseMeta.height) {
|
||||
throw new Error('Invalid baseline dimensions');
|
||||
}
|
||||
|
||||
// Produce PNG buffers of same size
|
||||
const [pngBaseBuf, pngCandBuf] = await Promise.all([
|
||||
sharp(baselineBuffer).png().toBuffer(),
|
||||
sharp(candidateBuffer)
|
||||
.resize(baseMeta.width, baseMeta.height)
|
||||
.png()
|
||||
.toBuffer(),
|
||||
]);
|
||||
|
||||
const imgBase = PNG.sync.read(pngBaseBuf);
|
||||
const imgCand = PNG.sync.read(pngCandBuf);
|
||||
const diffImg = new PNG({ width: baseMeta.width, height: baseMeta.height });
|
||||
|
||||
const numDiff = pixelmatch(
|
||||
imgBase.data,
|
||||
imgCand.data,
|
||||
diffImg.data,
|
||||
baseMeta.width,
|
||||
baseMeta.height,
|
||||
{ threshold }
|
||||
);
|
||||
|
||||
const total = baseMeta.width * baseMeta.height;
|
||||
const diffRatio = numDiff / total;
|
||||
|
||||
const result: CompareResult = { diffRatio };
|
||||
if (diffOutput) {
|
||||
result.diffBuffer = PNG.sync.write(diffImg);
|
||||
}
|
||||
|
||||
compareCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a baseline buffer and an array of screenshots, returns them sorted
|
||||
* ascending by diffRatio (least UI first).
|
||||
*/
|
||||
export async function rankScreenshots(
|
||||
baselineBuffer: Buffer,
|
||||
shots: Shot[],
|
||||
opts: CompareOpts = {}
|
||||
): Promise<RankedShot[]> {
|
||||
// Process up to 5 comparisons in parallel
|
||||
const compareLimit = pLimit(5);
|
||||
|
||||
// Run all comparisons with limited concurrency
|
||||
const results = await Promise.all(
|
||||
shots.map(shot =>
|
||||
compareLimit(async () => {
|
||||
const { diffRatio } = await compareWithBaseline(
|
||||
baselineBuffer,
|
||||
shot.buffer,
|
||||
opts
|
||||
);
|
||||
return { url: shot.url, score: diffRatio };
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return results.sort((a, b) => a.score - b.score);
|
||||
}
|
||||
|
||||
// --- Helpers for URLs ---
|
||||
export function getScreenshotUrls(screenshots: { appid: number; filename: string }[]): string[] {
|
||||
return screenshots.map(s => `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${s.appid}/${s.filename}`);
|
||||
}
|
||||
|
||||
export function getAssetUrls(assets: LibraryAssetsFull, appid: number | string, header: string) {
|
||||
const base = `https://shared.cloudflare.steamstatic.com/store_item_assets/steam/apps/${appid}`;
|
||||
return {
|
||||
logo: `${base}/${assets.library_logo?.image2x?.english || assets.library_logo?.image?.english}`,
|
||||
backdrop: `${base}/${assets.library_hero?.image2x?.english || assets.library_hero?.image?.english}`,
|
||||
poster: `${base}/${assets.library_capsule?.image2x?.english || assets.library_capsule?.image?.english}`,
|
||||
banner: `${base}/${assets.library_header?.image2x?.english || assets.library_header?.image?.english || header}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a 0–5 score from positive/negative votes using a Wilson score confidence interval.
|
||||
* This formula adjusts the raw ratio based on the total number of votes to account for
|
||||
* statistical confidence. With few votes, the score regresses toward 2.5 (neutral).
|
||||
*
|
||||
* Compute a 0–5 score from positive/negative votes
|
||||
*/
|
||||
export function getRating(positive: number, negative: number): number {
|
||||
const total = positive + negative;
|
||||
if (!total) return 0;
|
||||
const avg = positive / total;
|
||||
// Apply Wilson score confidence adjustment and scale to 0-5 range
|
||||
const score = avg - (avg - 0.5) * Math.pow(2, -Math.log10(total + 1));
|
||||
return Math.round(score * 5 * 10) / 10;
|
||||
}
|
||||
|
||||
export function getAssociationsByTypeWithSlug<
|
||||
T extends "developer" | "publisher"
|
||||
>(
|
||||
associations: Record<string, { name: string; type: string }>,
|
||||
type: T
|
||||
): Array<{ name: string; slug: string; type: T }> {
|
||||
return Object.values(associations)
|
||||
.filter((a) => a.type === type)
|
||||
.map((a) => ({ name: a.name.trim(), slug: createSlug(a.name.trim()), type }));
|
||||
}
|
||||
|
||||
export function compatibilityType(type?: string): "low" | "mid" | "high" | "unknown" {
|
||||
switch (type) {
|
||||
case "1":
|
||||
return "low";
|
||||
case "2":
|
||||
return "mid";
|
||||
case "3":
|
||||
return "high";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function mapGameTags<
|
||||
T extends string = "tag"
|
||||
>(
|
||||
available: Tag[],
|
||||
storeTags: StoreTags,
|
||||
): Array<{ name: string; slug: string; type: T }> {
|
||||
const tagMap = new Map<number, Tag>(available.map((t) => [t.tagid, t]));
|
||||
const result: Array<{ name: string; slug: string; type: T }> = Object.values(storeTags)
|
||||
.map((id) => tagMap.get(Number(id)))
|
||||
.filter((t): t is Tag => Boolean(t))
|
||||
.map((t) => ({ name: t.name.trim(), slug: createSlug(t.name), type: 'tag' as T }));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tag object with name, slug, and type
|
||||
* @typeparam T Literal type of the `type` field (defaults to 'tag')
|
||||
*/
|
||||
export function createTag<
|
||||
T extends string = 'tag'
|
||||
>(
|
||||
name: string,
|
||||
type?: T
|
||||
): { name: string; slug: string; type: T } {
|
||||
const tagType = (type ?? 'tag') as T;
|
||||
return {
|
||||
name: name.trim(),
|
||||
slug: createSlug(name),
|
||||
type: tagType,
|
||||
};
|
||||
}
|
||||
|
||||
export function capitalise(name: string) {
|
||||
return name
|
||||
.charAt(0) // first character
|
||||
.toUpperCase() // make it uppercase
|
||||
+ name
|
||||
.slice(1) // rest of the string
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function getPublicDepotSizes(depots: AppDepots) {
|
||||
const sum = { download: 0, size: 0 };
|
||||
for (const key in depots) {
|
||||
if (key === 'branches' || key === 'privatebranches') continue;
|
||||
const entry = depots[key] as DepotEntry;
|
||||
if ('manifests' in entry && entry.manifests.public) {
|
||||
sum.download += Number(entry.manifests.public.download);
|
||||
sum.size += Number(entry.manifests.public.size);
|
||||
}
|
||||
}
|
||||
return { downloadSize: sum.download, sizeOnDisk: sum.size };
|
||||
}
|
||||
|
||||
export function parseGenres(str: string): GenreType[] {
|
||||
return str.split(',')
|
||||
.map((g) => g.trim())
|
||||
.filter(Boolean)
|
||||
.map((g) => ({ type: 'genre', name: g.trim(), slug: createSlug(g) }));
|
||||
}
|
||||
|
||||
export function getPrimaryGenre(
|
||||
genres: GenreType[],
|
||||
map: Record<string, string>,
|
||||
primaryId: string
|
||||
): string | null {
|
||||
const idx = Object.keys(map).find((k) => map[k] === primaryId);
|
||||
return idx !== undefined ? genres[Number(idx)]?.name : null;
|
||||
}
|
||||
|
||||
export function cleanDescription(input: string): string {
|
||||
|
||||
const cleaned = sanitizeHtml(input, {
|
||||
allowedTags: [], // no tags allowed
|
||||
allowedAttributes: {}, // no attributes anywhere
|
||||
textFilter: (text) => text.replace(/\s+/g, ' '), // collapse runs of whitespace
|
||||
});
|
||||
|
||||
return cleaned.trim()
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import "zod-openapi/extend";
|
||||
|
||||
export namespace Common {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
export function createContext<T>() {
|
||||
export function createContext<T>(name: string) {
|
||||
const storage = new AsyncLocalStorage<T>();
|
||||
return {
|
||||
use() {
|
||||
const result = storage.getStore();
|
||||
if (!result) {
|
||||
throw new Error("No context available");
|
||||
throw new Error("Context not provided: " + name);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
provide<R>(value: T, fn: () => R) {
|
||||
return storage.run<R>(value, fn);
|
||||
with<R>(value: T, fn: () => R) {
|
||||
return storage.run<R, any[]>(value, fn);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
|
||||
import { encryptedText, ulid, timestamps, utc } from "../drizzle/types";
|
||||
|
||||
export const steamCredentialsTable = pgTable(
|
||||
"steam_account_credentials",
|
||||
{
|
||||
...timestamps,
|
||||
id: ulid("id").notNull(),
|
||||
steamID: varchar("steam_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => steamTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
refreshToken: encryptedText("refresh_token")
|
||||
.notNull(),
|
||||
expiry: utc("expiry").notNull(),
|
||||
username: varchar("username", { length: 255 }).notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.steamID, table.id]
|
||||
})
|
||||
]
|
||||
)
|
||||
@@ -1,93 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { Resource } from "sst";
|
||||
import { bus } from "sst/aws/bus";
|
||||
import { createEvent } from "../event";
|
||||
import { createID, fn } from "../utils";
|
||||
import { eq, and, isNull, gt } from "drizzle-orm";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { steamCredentialsTable } from "./credentials.sql";
|
||||
import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Credentials {
|
||||
export const Info = createSelectSchema(steamCredentialsTable)
|
||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
||||
.extend({
|
||||
accessToken: z.string(),
|
||||
cookies: z.string().array()
|
||||
})
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Events = {
|
||||
New: createEvent(
|
||||
"new_credentials.added",
|
||||
z.object({
|
||||
steamID: Info.shape.steamID,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const create = fn(
|
||||
Info
|
||||
.omit({ accessToken: true, cookies: true, expiry: true })
|
||||
.partial({ id: true }),
|
||||
(input) => {
|
||||
const part = input.refreshToken.split('.')[1] as string
|
||||
|
||||
const payload = JSON.parse(Buffer.from(part, 'base64').toString());
|
||||
|
||||
return createTransaction(async (tx) => {
|
||||
const id = input.id ?? createID("credentials")
|
||||
await tx
|
||||
.insert(steamCredentialsTable)
|
||||
.values({
|
||||
id,
|
||||
steamID: input.steamID,
|
||||
username: input.username,
|
||||
refreshToken: input.refreshToken,
|
||||
expiry: new Date(payload.exp * 1000),
|
||||
})
|
||||
await afterTx(async () =>
|
||||
await bus.publish(Resource.Bus, Events.New, { steamID: input.steamID })
|
||||
);
|
||||
return id
|
||||
})
|
||||
});
|
||||
|
||||
export const fromSteamID = fn(
|
||||
Info.shape.steamID,
|
||||
(steamID) =>
|
||||
useTransaction(async (tx) => {
|
||||
const now = new Date()
|
||||
|
||||
const credential = await tx
|
||||
.select()
|
||||
.from(steamCredentialsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(steamCredentialsTable.steamID, steamID),
|
||||
isNull(steamCredentialsTable.timeDeleted),
|
||||
gt(steamCredentialsTable.expiry, now)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
.then(rows => rows.at(0));
|
||||
|
||||
if (!credential) return null;
|
||||
|
||||
return serialize(credential);
|
||||
})
|
||||
);
|
||||
|
||||
export function serialize(
|
||||
input: typeof steamCredentialsTable.$inferSelect,
|
||||
) {
|
||||
return {
|
||||
id: input.id,
|
||||
expiry: input.expiry,
|
||||
steamID: input.steamID,
|
||||
username: input.username,
|
||||
refreshToken: input.refreshToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "drizzle-orm";
|
||||
import { Resource } from "sst";
|
||||
import postgres from "postgres";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
|
||||
@@ -20,7 +20,7 @@ type TxOrDb = Transaction | typeof db;
|
||||
const TransactionContext = createContext<{
|
||||
tx: Transaction;
|
||||
effects: (() => void | Promise<void>)[];
|
||||
}>();
|
||||
}>("TransactionContext");
|
||||
|
||||
export async function useTransaction<T>(callback: (trx: TxOrDb) => Promise<T>) {
|
||||
try {
|
||||
@@ -51,7 +51,7 @@ export async function createTransaction<T>(
|
||||
const effects: (() => void | Promise<void>)[] = [];
|
||||
const result = await db.transaction(
|
||||
async (tx) => {
|
||||
return TransactionContext.provide({ tx, effects }, () => callback(tx));
|
||||
return TransactionContext.with({ tx, effects }, () => callback(tx));
|
||||
},
|
||||
{
|
||||
isolationLevel: isolationLevel || "read committed",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Token } from "../utils";
|
||||
import { char, customType, timestamp as rawTs } from "drizzle-orm/pg-core";
|
||||
import { char, timestamp as rawTs } from "drizzle-orm/pg-core";
|
||||
import { teamTable } from "../team/team.sql";
|
||||
|
||||
export const ulid = (name: string) => char(name, { length: 26 + 4 });
|
||||
|
||||
@@ -33,19 +33,6 @@ export const utc = (name: string) =>
|
||||
// mode: "date"
|
||||
});
|
||||
|
||||
export const encryptedText =
|
||||
customType<{ data: string; driverData: string; }>({
|
||||
dataType() {
|
||||
return 'text';
|
||||
},
|
||||
fromDriver(val) {
|
||||
return Token.decrypt(val);
|
||||
},
|
||||
toDriver(val) {
|
||||
return Token.encrypt(val);
|
||||
},
|
||||
});
|
||||
|
||||
export const timestamps = {
|
||||
timeCreated: utc("time_created").notNull().defaultNow(),
|
||||
timeUpdated: utc("time_updated").notNull().defaultNow(),
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { Actor } from "./actor";
|
||||
import { event } from "sst/event";
|
||||
import { useActor } from "./actor";
|
||||
import { event as sstEvent } from "sst/event";
|
||||
import { ZodValidator } from "sst/event/validator";
|
||||
|
||||
export const createEvent = event.builder({
|
||||
export const createEvent = sstEvent.builder({
|
||||
validator: ZodValidator,
|
||||
metadata() {
|
||||
return {
|
||||
actor: Actor.use(),
|
||||
actor: useActor(),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
import { openevent } from "@openauthjs/openevent/event";
|
||||
export { publish } from "@openauthjs/openevent/publisher/drizzle";
|
||||
|
||||
export const event = openevent({
|
||||
metadata() {
|
||||
return {
|
||||
actor: useActor(),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,30 +1,76 @@
|
||||
import { prefixes } from "./utils";
|
||||
|
||||
export namespace Examples {
|
||||
export const Id = (prefix: keyof typeof prefixes) =>
|
||||
`${prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`;
|
||||
|
||||
export const User = {
|
||||
id: Id("user"),// Primary key
|
||||
name: "John Doe", // Name (not null)
|
||||
email: "johndoe@example.com",// Unique email or login (not null)
|
||||
avatarUrl: "https://cdn.discordapp.com/avatars/xxxxxxx/xxxxxxx.png",
|
||||
lastLogin: new Date("2025-04-26T20:11:08.155Z"),
|
||||
polarCustomerID: "0bfcb712-df13-4454-81a8-fbee66eddca4"
|
||||
export const Steam = {
|
||||
id: Id("steam"),
|
||||
userID: Id("user"),
|
||||
countryCode: "KE",
|
||||
steamID: 74839300282033,
|
||||
limitation: {
|
||||
isLimited: false,
|
||||
isBanned: false,
|
||||
isLocked: false,
|
||||
isAllowedToInviteFriends: false,
|
||||
},
|
||||
lastGame: {
|
||||
gameID: 2531310,
|
||||
gameName: "The Last of Us™ Part II Remastered",
|
||||
},
|
||||
personaName: "John",
|
||||
username: "johnsteamaccount",
|
||||
steamEmail: "john@example.com",
|
||||
avatarUrl: "https://avatars.akamai.steamstatic.com/XXXXXXXXXXXX_full.jpg",
|
||||
}
|
||||
|
||||
export const GPUType = {
|
||||
id: Id("gpu"),
|
||||
type: "hosted" as const, //or BYOG - Bring Your Own GPU
|
||||
name: "RTX 4090" as const, // or RTX 3090, Intel Arc
|
||||
performanceTier: 3,
|
||||
maxResolution: "4k"
|
||||
export const User = {
|
||||
id: Id("user"),
|
||||
name: "John Doe",
|
||||
email: "john@example.com",
|
||||
discriminator: 47,
|
||||
avatarUrl: "https://cdn.discordapp.com/avatars/xxxxxxx/xxxxxxx.png",
|
||||
polarCustomerID: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
steamAccounts: [Steam]
|
||||
};
|
||||
|
||||
export const Product = {
|
||||
id: Id("product"),
|
||||
name: "RTX 4090",
|
||||
description: "Ideal for dedicated gamers who crave more flexibility and social gaming experiences.",
|
||||
tokensPerHour: 20,
|
||||
}
|
||||
|
||||
export const Subscription = {
|
||||
tokens: 100,
|
||||
id: Id("subscription"),
|
||||
userID: Id("user"),
|
||||
teamID: Id("team"),
|
||||
planType: "pro" as const, // free, pro, family, enterprise
|
||||
standing: "new" as const, // new, good, overdue, cancelled
|
||||
polarProductID: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
polarSubscriptionID: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
}
|
||||
|
||||
export const Member = {
|
||||
id: Id("member"),
|
||||
email: "john@example.com",
|
||||
teamID: Id("team"),
|
||||
role: "admin" as const,
|
||||
timeSeen: new Date("2025-02-23T13:39:52.249Z"),
|
||||
}
|
||||
|
||||
export const Team = {
|
||||
id: Id("team"),
|
||||
name: "John Does' Team",
|
||||
slug: "john_doe",
|
||||
subscriptions: [Subscription],
|
||||
members: [Member]
|
||||
}
|
||||
|
||||
export const Machine = {
|
||||
id: Id("machine"),
|
||||
ownerID: User.id, //or null if hosted
|
||||
gpuID: GPUType.id, // or hosted
|
||||
userID: Id("user"),
|
||||
country: "Kenya",
|
||||
countryCode: "KE",
|
||||
timezone: "Africa/Nairobi",
|
||||
@@ -32,235 +78,4 @@ export namespace Examples {
|
||||
fingerprint: "fc27f428f9ca47d4b41b707ae0c62090",
|
||||
}
|
||||
|
||||
export const SteamAccount = {
|
||||
status: "online" as const, //offline,dnd(do not disturb) or playing
|
||||
id: "74839300282033",// Primary key
|
||||
userID: User.id,// | null FK to User (null if not linked)
|
||||
name: "JD The 65th",
|
||||
username: "jdoe",
|
||||
realName: "John Doe",
|
||||
steamMemberSince: new Date("2010-01-26T21:00:00.000Z"),
|
||||
avatarHash: "3a5e805fd4c1e04e26a97af0b9c6fab2dee91a19",
|
||||
accountStatus: "new" as const, //active or pending
|
||||
limitations: {
|
||||
isLimited: false,
|
||||
tradeBanState: "none" as const,
|
||||
isVacBanned: false,
|
||||
visibilityState: 3,
|
||||
privacyState: "public" as const,
|
||||
},
|
||||
profileUrl: "The65thJD", //"https://steamcommunity.com/id/XXXXXXXXXXXXXXXX/",
|
||||
lastSyncedAt: new Date("2025-04-26T20:11:08.155Z")
|
||||
};
|
||||
|
||||
export const Team = {
|
||||
id: Id("team"),// Primary key
|
||||
name: "John's Console", // Team name (not null, unique)
|
||||
ownerID: User.id, // FK to User who owns/created the team
|
||||
slug: SteamAccount.profileUrl.toLowerCase(),
|
||||
maxMembers: 3,
|
||||
inviteCode: "xwydjf",
|
||||
members: [SteamAccount]
|
||||
};
|
||||
|
||||
export const Member = {
|
||||
id: Id("member"),
|
||||
userID: User.id,//FK to Users (member)
|
||||
steamID: SteamAccount.id, // FK to the Steam Account this member is used
|
||||
teamID: Team.id,// FK to Teams
|
||||
role: "adult" as const, // Role on the team, adult or child
|
||||
};
|
||||
|
||||
export const ProductVariant = {
|
||||
id: Id("variant"),
|
||||
productID: Id("product"),// the product this variant is under
|
||||
type: "fixed" as const, // or yearly or monthly,
|
||||
price: 1999,
|
||||
minutesPerDay: 3600,
|
||||
polarProductID: "0bfcb712-df13-4454-81a8-fbee66eddca4"
|
||||
}
|
||||
|
||||
export const Product = {
|
||||
id: Id("product"),
|
||||
name: "Pro",
|
||||
description: "For gamers who want to play on a better GPU and with 2 more friends",
|
||||
maxMembers: Team.maxMembers,// Total number of people who can share this sub
|
||||
isActive: true,
|
||||
order: 2,
|
||||
variants: [ProductVariant]
|
||||
}
|
||||
|
||||
export const Friend = {
|
||||
...Examples.SteamAccount,
|
||||
user: Examples.User
|
||||
}
|
||||
|
||||
export const Subscription = {
|
||||
id: Id("subscription"),
|
||||
teamID: Team.id,
|
||||
standing: "active" as const, //incomplete, incomplete_expired, trialing, active, past_due, canceled, unpaid
|
||||
ownerID: User.id,
|
||||
price: ProductVariant.price,
|
||||
productVariantID: ProductVariant.id,
|
||||
polarSubscriptionID: "0bfcb712-df13-4454-81a8-fbee66eddca4",
|
||||
}
|
||||
|
||||
export const SubscriptionUsage = {
|
||||
id: Id("usage"),
|
||||
machineID: Machine.id, // machine this session was used on
|
||||
memberID: Member.id, // the team member who used it
|
||||
subscriptionID: Subscription.id,
|
||||
sessionID: Id("session"),
|
||||
minutesUsed: 20, // Minutes used on the session
|
||||
}
|
||||
|
||||
export const Session = {
|
||||
id: Id("session"),
|
||||
memberID: Member.id,
|
||||
machineID: Machine.id,
|
||||
startTime: new Date("2025-02-23T23:39:52.249Z"),
|
||||
endTime: null, // null if session is ongoing
|
||||
gameID: Id("game"),
|
||||
status: "active" as const, // active, completed, crashed
|
||||
}
|
||||
|
||||
export const GameGenre = {
|
||||
type: "genre" as const,
|
||||
slug: "action",
|
||||
name: "Action"
|
||||
}
|
||||
|
||||
export const GameTag = {
|
||||
type: "tag" as const,
|
||||
slug: "single-player",
|
||||
name: "Single Player"
|
||||
}
|
||||
|
||||
export const GameRating = {
|
||||
body: "ESRB" as const, // or PEGI
|
||||
age: 16,
|
||||
descriptors: ["Blood", "Violence", "Strong Language"],
|
||||
}
|
||||
|
||||
export const DevelopmentTeam = {
|
||||
type: "developer" as const,
|
||||
name: "Remedy Entertainment",
|
||||
slug: "remedy_entertainment",
|
||||
}
|
||||
|
||||
export const BaseGame = {
|
||||
id: "1809540",
|
||||
slug: "nine-sols",
|
||||
name: "Nine Sols",
|
||||
controllerSupport: "full" as const,
|
||||
releaseDate: new Date("2024-05-29T06:53:24.000Z"),
|
||||
compatibility: "high" as const,
|
||||
size: {
|
||||
downloadSize: 7907568608,// 7.91 GB
|
||||
sizeOnDisk: 13176088178,// 13.18 GB
|
||||
},
|
||||
primaryGenre: "Action",
|
||||
score: 4.7,
|
||||
description: "Nine Sols is a lore rich, hand-drawn 2D action-platformer featuring Sekiro-inspired deflection focused combat. Embark on a journey of eastern fantasy, explore the land once home to an ancient alien race, and follow a vengeful hero’s quest to slay the 9 Sols, formidable rulers of this forsaken realm.",
|
||||
}
|
||||
|
||||
export const Categories = {
|
||||
genres: [
|
||||
{
|
||||
name: "Action",
|
||||
slug: "action"
|
||||
},
|
||||
{
|
||||
name: "Adventure",
|
||||
slug: "adventure"
|
||||
},
|
||||
{
|
||||
name: "Indie",
|
||||
slug: "indie"
|
||||
}
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: "Metroidvania",
|
||||
slug: "metroidvania",
|
||||
},
|
||||
{
|
||||
name: "Souls-like",
|
||||
slug: "souls-like",
|
||||
},
|
||||
{
|
||||
name: "Difficult",
|
||||
slug: "difficult",
|
||||
},
|
||||
],
|
||||
developers: [
|
||||
{
|
||||
name: "RedCandleGames",
|
||||
slug: "redcandlegames"
|
||||
}
|
||||
],
|
||||
publishers: [
|
||||
{
|
||||
name: "RedCandleGames",
|
||||
slug: "redcandlegames"
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
export const CommonImg = [
|
||||
{
|
||||
hash: "db880dc2f0187bfe0c5d3c44a06d1002351eb3107970a83bf5667ffd3b369acd",
|
||||
averageColor: {
|
||||
hex: "#352c36",
|
||||
isDark: true
|
||||
},
|
||||
dimensions: {
|
||||
width: 3840,
|
||||
height: 2160
|
||||
},
|
||||
fileSize: 976004
|
||||
},
|
||||
{
|
||||
hash: "99f603e41dd3efde21a145fd00c9f107025c09433c084a5e5005bc2ac30e46ea",
|
||||
averageColor: {
|
||||
hex: "#596774",
|
||||
isDark: true
|
||||
},
|
||||
dimensions: {
|
||||
width: 2560,
|
||||
height: 1440
|
||||
},
|
||||
fileSize: 895134
|
||||
},
|
||||
{
|
||||
hash: "2c4193c19160392be01d08e6957ed682649117742c5abaa8c469e7408382572f",
|
||||
averageColor: {
|
||||
hex: "#444b5b",
|
||||
isDark: true
|
||||
},
|
||||
dimensions: {
|
||||
width: 2560,
|
||||
height: 1440
|
||||
},
|
||||
fileSize: 738701
|
||||
}
|
||||
]
|
||||
|
||||
// type: "screenshots" as const, // or boxart(square), poster(vertical), superheroart(background), heroart(horizontal), logo, icon
|
||||
export const Images = {
|
||||
screenshots: CommonImg,
|
||||
boxArts: CommonImg,
|
||||
posters: CommonImg,
|
||||
banners: CommonImg,
|
||||
heroArts: CommonImg,
|
||||
backdrops: CommonImg,
|
||||
logos: CommonImg,
|
||||
icons: CommonImg,
|
||||
}
|
||||
|
||||
export const Game = {
|
||||
...BaseGame,
|
||||
...Categories,
|
||||
...Images
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { timestamps, } from "../drizzle/types";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { index, pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const friendTable = pgTable(
|
||||
"friends_list",
|
||||
{
|
||||
...timestamps,
|
||||
steamID: varchar("steam_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => steamTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
friendSteamID: varchar("friend_steam_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => steamTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.steamID, table.friendSteamID]
|
||||
}),
|
||||
index("idx_friends_list_friend_steam_id").on(table.friendSteamID),
|
||||
]
|
||||
);
|
||||
@@ -1,190 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { User } from "../user";
|
||||
import { Steam } from "../steam";
|
||||
import { Actor } from "../actor";
|
||||
import { Examples } from "../examples";
|
||||
import { friendTable } from "./friend.sql";
|
||||
import { userTable } from "../user/user.sql";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { groupBy, map, pipe, values } from "remeda";
|
||||
import { ErrorCodes, VisibleError } from "../error";
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Friend {
|
||||
export const Info = Steam.Info
|
||||
.extend({
|
||||
user: User.Info.nullable().openapi({
|
||||
description: "The user account that owns this Steam account",
|
||||
example: Examples.User
|
||||
})
|
||||
})
|
||||
.openapi({
|
||||
ref: "Friend",
|
||||
description: "Represents a friend's information stored on Nestri",
|
||||
example: Examples.Friend,
|
||||
});
|
||||
|
||||
|
||||
export const InputInfo = createSelectSchema(friendTable)
|
||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
export type InputInfo = z.infer<typeof InputInfo>;
|
||||
|
||||
export const add = fn(
|
||||
InputInfo.partial({ steamID: true }),
|
||||
async (input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const steamID = input.steamID ?? Actor.steamID()
|
||||
if (steamID === input.friendSteamID) {
|
||||
throw new VisibleError(
|
||||
"forbidden",
|
||||
ErrorCodes.Validation.INVALID_PARAMETER,
|
||||
"Cannot add yourself as a friend"
|
||||
);
|
||||
}
|
||||
|
||||
const results =
|
||||
await tx
|
||||
.select()
|
||||
.from(friendTable)
|
||||
.where(
|
||||
and(
|
||||
eq(friendTable.steamID, steamID),
|
||||
eq(friendTable.friendSteamID, input.friendSteamID),
|
||||
isNull(friendTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
|
||||
if (results.length > 0) return null
|
||||
|
||||
await tx
|
||||
.insert(friendTable)
|
||||
.values({
|
||||
steamID,
|
||||
friendSteamID: input.friendSteamID
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [friendTable.steamID, friendTable.friendSteamID],
|
||||
set: { timeDeleted: null }
|
||||
})
|
||||
|
||||
return steamID
|
||||
}),
|
||||
)
|
||||
|
||||
export const end = fn(
|
||||
InputInfo,
|
||||
(input) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.update(friendTable)
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(
|
||||
and(
|
||||
eq(friendTable.steamID, input.steamID),
|
||||
eq(friendTable.friendSteamID, input.friendSteamID),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
export const list = () =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select({
|
||||
steam: steamTable,
|
||||
user: userTable,
|
||||
})
|
||||
.from(friendTable)
|
||||
.innerJoin(
|
||||
steamTable,
|
||||
eq(friendTable.friendSteamID, steamTable.id)
|
||||
)
|
||||
.leftJoin(
|
||||
userTable,
|
||||
eq(steamTable.userID, userTable.id)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(friendTable.steamID, Actor.steamID()),
|
||||
isNull(friendTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(100)
|
||||
.execute()
|
||||
.then(rows => serialize(rows))
|
||||
)
|
||||
|
||||
export const fromFriendID = fn(
|
||||
InputInfo.shape.friendSteamID,
|
||||
(friendSteamID) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select({
|
||||
steam: steamTable,
|
||||
user: userTable,
|
||||
})
|
||||
.from(friendTable)
|
||||
.innerJoin(
|
||||
steamTable,
|
||||
eq(friendTable.friendSteamID, steamTable.id)
|
||||
)
|
||||
.leftJoin(
|
||||
userTable,
|
||||
eq(steamTable.userID, userTable.id)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(friendTable.steamID, Actor.steamID()),
|
||||
eq(friendTable.friendSteamID, friendSteamID),
|
||||
isNull(friendTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then(rows => serialize(rows).at(0))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
export const areFriends = fn(
|
||||
InputInfo.shape.friendSteamID,
|
||||
(friendSteamID) =>
|
||||
useTransaction(async (tx) => {
|
||||
const result = await tx
|
||||
.select()
|
||||
.from(friendTable)
|
||||
.where(
|
||||
and(
|
||||
eq(friendTable.steamID, Actor.steamID()),
|
||||
eq(friendTable.friendSteamID, friendSteamID),
|
||||
isNull(friendTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.execute()
|
||||
|
||||
return result.length > 0
|
||||
})
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: { user: typeof userTable.$inferSelect | null; steam: typeof steamTable.$inferSelect }[],
|
||||
): z.infer<typeof Info>[] {
|
||||
return pipe(
|
||||
input,
|
||||
groupBy((row) => row.steam.id),
|
||||
values(),
|
||||
map((group) => ({
|
||||
...Steam.serialize(group[0].steam),
|
||||
user: group[0].user ? User.serialize(group[0].user!) : null
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { timestamps } from "../drizzle/types";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { categoriesTable, CategoryTypeEnum } from "../categories/categories.sql";
|
||||
import { foreignKey, index, pgTable, primaryKey, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const gamesTable = pgTable(
|
||||
'games',
|
||||
{
|
||||
...timestamps,
|
||||
baseGameID: varchar('base_game_id', { length: 255 })
|
||||
.notNull()
|
||||
.references(() => baseGamesTable.id,
|
||||
{ onDelete: "cascade" }
|
||||
),
|
||||
categorySlug: varchar('category_slug', { length: 255 })
|
||||
.notNull(),
|
||||
categoryType: CategoryTypeEnum("type").notNull()
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.baseGameID, table.categorySlug, table.categoryType]
|
||||
}),
|
||||
foreignKey({
|
||||
name: "games_categories_fkey",
|
||||
columns: [table.categorySlug, table.categoryType],
|
||||
foreignColumns: [categoriesTable.slug, categoriesTable.type],
|
||||
}).onDelete("cascade"),
|
||||
index("idx_games_category_slug").on(table.categorySlug),
|
||||
index("idx_games_category_type").on(table.categoryType),
|
||||
index("idx_games_category_slug_type").on(
|
||||
table.categorySlug,
|
||||
table.categoryType
|
||||
)
|
||||
]
|
||||
);
|
||||
@@ -1,129 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Images } from "../images";
|
||||
import { Examples } from "../examples";
|
||||
import { BaseGame } from "../base-game";
|
||||
import { gamesTable } from "./game.sql";
|
||||
import { Categories } from "../categories";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { imagesTable } from "../images/images.sql";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { groupBy, map, pipe, uniqueBy, values } from "remeda";
|
||||
import { categoriesTable } from "../categories/categories.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Game {
|
||||
export const Info = z
|
||||
.intersection(BaseGame.Info, Categories.Info, Images.Info)
|
||||
.openapi({
|
||||
ref: "Game",
|
||||
description: "Detailed information about a game available in the Nestri library, including technical specifications, categories and metadata",
|
||||
example: Examples.Game
|
||||
})
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const InputInfo = createSelectSchema(gamesTable)
|
||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
||||
|
||||
export const create = fn(
|
||||
InputInfo,
|
||||
(input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const result =
|
||||
await tx
|
||||
.select()
|
||||
.from(gamesTable)
|
||||
.where(
|
||||
and(
|
||||
eq(gamesTable.categorySlug, input.categorySlug),
|
||||
eq(gamesTable.categoryType, input.categoryType),
|
||||
eq(gamesTable.baseGameID, input.baseGameID),
|
||||
isNull(gamesTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then(rows => rows.at(0))
|
||||
|
||||
if (result) return result.baseGameID
|
||||
|
||||
await tx
|
||||
.insert(gamesTable)
|
||||
.values(input)
|
||||
.onConflictDoUpdate({
|
||||
target: [gamesTable.categorySlug, gamesTable.categoryType, gamesTable.baseGameID],
|
||||
set: { timeDeleted: null }
|
||||
})
|
||||
|
||||
return input.baseGameID
|
||||
})
|
||||
)
|
||||
|
||||
export const fromID = fn(
|
||||
InputInfo.shape.baseGameID,
|
||||
(gameID) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select({
|
||||
games: baseGamesTable,
|
||||
categories: categoriesTable,
|
||||
images: imagesTable
|
||||
})
|
||||
.from(gamesTable)
|
||||
.innerJoin(baseGamesTable,
|
||||
eq(baseGamesTable.id, gamesTable.baseGameID)
|
||||
)
|
||||
.leftJoin(categoriesTable,
|
||||
and(
|
||||
eq(categoriesTable.slug, gamesTable.categorySlug),
|
||||
eq(categoriesTable.type, gamesTable.categoryType),
|
||||
)
|
||||
)
|
||||
.leftJoin(imagesTable,
|
||||
and(
|
||||
eq(imagesTable.baseGameID, gamesTable.baseGameID),
|
||||
isNull(imagesTable.timeDeleted),
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(gamesTable.baseGameID, gameID),
|
||||
isNull(gamesTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
.then((rows) => serialize(rows).at(0))
|
||||
)
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: { games: typeof baseGamesTable.$inferSelect; categories: typeof categoriesTable.$inferSelect | null; images: typeof imagesTable.$inferSelect | null }[],
|
||||
): z.infer<typeof Info>[] {
|
||||
return pipe(
|
||||
input,
|
||||
groupBy((row) => row.games.id),
|
||||
values(),
|
||||
map((group) => {
|
||||
const game = BaseGame.serialize(group[0].games)
|
||||
const cats = uniqueBy(
|
||||
group.map(r => r.categories).filter((c): c is typeof categoriesTable.$inferSelect => Boolean(c)),
|
||||
(c) => `${c.slug}:${c.type}`
|
||||
)
|
||||
const imgs = uniqueBy(
|
||||
group.map(r => r.images).filter((c): c is typeof imagesTable.$inferSelect => Boolean(c)),
|
||||
(c) => `${c.type}:${c.imageHash}:${c.position}`
|
||||
)
|
||||
const byType = Categories.serialize(cats)
|
||||
const byImg = Images.serialize(imgs)
|
||||
return {
|
||||
...game,
|
||||
...byType,
|
||||
...byImg
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { timestamps } from "../drizzle/types";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { index, integer, json, pgEnum, pgTable, primaryKey, text, varchar } from "drizzle-orm/pg-core";
|
||||
|
||||
export const ImageTypeEnum = pgEnum("image_type", ["heroArt", "icon", "logo", "banner", "poster", "boxArt", "screenshot", "backdrop"])
|
||||
|
||||
export const ImageDimensions = z.object({
|
||||
width: z.number().int(),
|
||||
height: z.number().int(),
|
||||
})
|
||||
|
||||
export const ImageColor = z.object({
|
||||
hex: z.string(),
|
||||
isDark: z.boolean()
|
||||
})
|
||||
|
||||
export type ImageColor = z.infer<typeof ImageColor>;
|
||||
export type ImageDimensions = z.infer<typeof ImageDimensions>;
|
||||
|
||||
export const imagesTable = pgTable(
|
||||
"images",
|
||||
{
|
||||
...timestamps,
|
||||
type: ImageTypeEnum("type").notNull(),
|
||||
imageHash: varchar("image_hash", { length: 255 })
|
||||
.notNull(),
|
||||
baseGameID: varchar("base_game_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => baseGamesTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
sourceUrl: text("source_url"), // The BoxArt is source Url will always be null;
|
||||
position: integer("position").notNull().default(0),
|
||||
fileSize: integer("file_size").notNull(),
|
||||
dimensions: json("dimensions").$type<ImageDimensions>().notNull(),
|
||||
extractedColor: json("extracted_color").$type<ImageColor>().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.imageHash, table.type, table.baseGameID, table.position]
|
||||
}),
|
||||
index("idx_images_type").on(table.type),
|
||||
index("idx_images_game_id").on(table.baseGameID),
|
||||
]
|
||||
)
|
||||
@@ -1,119 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Examples } from "../examples";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { createTransaction } from "../drizzle/transaction";
|
||||
import { ImageColor, ImageDimensions, imagesTable } from "./images.sql";
|
||||
|
||||
export namespace Images {
|
||||
const Image = z.object({
|
||||
hash: z.string().openapi({
|
||||
description: "A unique cryptographic hash identifier for the image, used for deduplication and URL generation",
|
||||
example: Examples.CommonImg[0].hash
|
||||
}),
|
||||
averageColor: ImageColor.openapi({
|
||||
description: "The calculated dominant color of the image with light/dark classification, used for UI theming",
|
||||
example: Examples.CommonImg[0].averageColor
|
||||
}),
|
||||
dimensions: ImageDimensions.openapi({
|
||||
description: "The width and height dimensions of the image in pixels",
|
||||
example: Examples.CommonImg[0].dimensions
|
||||
}),
|
||||
fileSize: z.number().int().openapi({
|
||||
description: "The size of the image file in bytes, used for storage and bandwidth calculations",
|
||||
example: Examples.CommonImg[0].fileSize
|
||||
})
|
||||
})
|
||||
|
||||
export const Info = z.object({
|
||||
screenshots: Image.array().openapi({
|
||||
description: "In-game captured images showing actual gameplay, user interface, and key moments",
|
||||
example: Examples.Images.screenshots
|
||||
}),
|
||||
boxArts: Image.array().openapi({
|
||||
description: "Square 1:1 aspect ratio artwork, typically used for store listings and thumbnails",
|
||||
example: Examples.Images.boxArts
|
||||
}),
|
||||
posters: Image.array().openapi({
|
||||
description: "Vertical 2:3 aspect ratio promotional artwork, similar to movie posters",
|
||||
example: Examples.Images.posters
|
||||
}),
|
||||
banners: Image.array().openapi({
|
||||
description: "Horizontal promotional artwork optimized for header displays and banners",
|
||||
example: Examples.Images.banners
|
||||
}),
|
||||
heroArts: Image.array().openapi({
|
||||
description: "High-resolution, wide-format artwork designed for featured content and main entries",
|
||||
example: Examples.Images.heroArts
|
||||
}),
|
||||
backdrops: Image.array().openapi({
|
||||
description: "Full-width backdrop images optimized for page layouts and decorative purposes",
|
||||
example: Examples.Images.backdrops
|
||||
}),
|
||||
logos: Image.array().openapi({
|
||||
description: "Official game logo artwork, typically with transparent backgrounds for flexible placement",
|
||||
example: Examples.Images.logos
|
||||
}),
|
||||
icons: Image.array().openapi({
|
||||
description: "Small-format identifiers used for application shortcuts and compact displays",
|
||||
example: Examples.Images.icons
|
||||
}),
|
||||
}).openapi({
|
||||
ref: "Images",
|
||||
description: "Complete collection of game-related visual assets, including promotional materials, UI elements, and store assets",
|
||||
example: Examples.Images
|
||||
})
|
||||
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const InputInfo = createSelectSchema(imagesTable)
|
||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
||||
|
||||
export const create = fn(
|
||||
InputInfo,
|
||||
(input) =>
|
||||
createTransaction(async (tx) =>
|
||||
tx
|
||||
.insert(imagesTable)
|
||||
.values(input)
|
||||
.onConflictDoUpdate({
|
||||
target: [imagesTable.imageHash, imagesTable.type, imagesTable.baseGameID, imagesTable.position],
|
||||
set: { timeDeleted: null }
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export function serialize(
|
||||
input: typeof imagesTable.$inferSelect[],
|
||||
): z.infer<typeof Info> {
|
||||
return input
|
||||
.sort((a, b) => {
|
||||
if (a.type === b.type) {
|
||||
return a.position - b.position;
|
||||
}
|
||||
return a.type.localeCompare(b.type);
|
||||
})
|
||||
.reduce<Record<`${typeof imagesTable.$inferSelect["type"]}s`, { hash: string; averageColor: ImageColor; dimensions: ImageDimensions; fileSize: number }[]>>((acc, img) => {
|
||||
const key = `${img.type}s` as `${typeof img.type}s`
|
||||
if (Array.isArray(acc[key])) {
|
||||
acc[key]!.push({
|
||||
hash: img.imageHash,
|
||||
averageColor: img.extractedColor,
|
||||
dimensions: img.dimensions,
|
||||
fileSize: img.fileSize
|
||||
})
|
||||
}
|
||||
return acc
|
||||
}, {
|
||||
screenshots: [],
|
||||
boxArts: [],
|
||||
banners: [],
|
||||
heroArts: [],
|
||||
posters: [],
|
||||
backdrops: [],
|
||||
icons: [],
|
||||
logos: [],
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { z } from "zod";
|
||||
import { fn } from "../utils";
|
||||
import { Game } from "../game";
|
||||
import { Actor } from "../actor";
|
||||
import { createEvent } from "../event";
|
||||
import { gamesTable } from "../game/game.sql";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import { steamLibraryTable } from "./library.sql";
|
||||
import { imagesTable } from "../images/images.sql";
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { categoriesTable } from "../categories/categories.sql";
|
||||
import { createTransaction, useTransaction } from "../drizzle/transaction";
|
||||
|
||||
export namespace Library {
|
||||
export const Info = createSelectSchema(steamLibraryTable)
|
||||
.omit({ timeCreated: true, timeDeleted: true, timeUpdated: true })
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const Events = {
|
||||
Queue: createEvent(
|
||||
"library.queue",
|
||||
z.object({
|
||||
appID: z.number(),
|
||||
lastPlayed: z.date(),
|
||||
timeAcquired: z.date(),
|
||||
totalPlaytime: z.number(),
|
||||
isFamilyShared: z.boolean(),
|
||||
isFamilyShareable: z.boolean(),
|
||||
}).array(),
|
||||
),
|
||||
};
|
||||
|
||||
export const add = fn(
|
||||
Info.partial({ ownerID: true }),
|
||||
async (input) =>
|
||||
createTransaction(async (tx) => {
|
||||
const ownerSteamID = input.ownerID ?? Actor.steamID()
|
||||
const result =
|
||||
await tx
|
||||
.select()
|
||||
.from(steamLibraryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(steamLibraryTable.baseGameID, input.baseGameID),
|
||||
eq(steamLibraryTable.ownerID, ownerSteamID),
|
||||
isNull(steamLibraryTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
.execute()
|
||||
.then(rows => rows.at(0))
|
||||
|
||||
if (result) return result.baseGameID
|
||||
|
||||
await tx
|
||||
.insert(steamLibraryTable)
|
||||
.values({
|
||||
ownerID: ownerSteamID,
|
||||
baseGameID: input.baseGameID,
|
||||
lastPlayed: input.lastPlayed,
|
||||
totalPlaytime: input.totalPlaytime,
|
||||
timeAcquired: input.timeAcquired,
|
||||
isFamilyShared: input.isFamilyShared
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [steamLibraryTable.ownerID, steamLibraryTable.baseGameID],
|
||||
set: {
|
||||
timeDeleted: null,
|
||||
lastPlayed: input.lastPlayed,
|
||||
timeAcquired: input.timeAcquired,
|
||||
totalPlaytime: input.totalPlaytime,
|
||||
isFamilyShared: input.isFamilyShared
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
)
|
||||
|
||||
export const remove = fn(
|
||||
Info,
|
||||
(input) =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.update(steamLibraryTable)
|
||||
.set({ timeDeleted: sql`now()` })
|
||||
.where(
|
||||
and(
|
||||
eq(steamLibraryTable.ownerID, input.ownerID),
|
||||
eq(steamLibraryTable.baseGameID, input.baseGameID),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
export const list = () =>
|
||||
useTransaction(async (tx) =>
|
||||
tx
|
||||
.select({
|
||||
games: baseGamesTable,
|
||||
categories: categoriesTable,
|
||||
images: imagesTable
|
||||
})
|
||||
.from(steamLibraryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(steamLibraryTable.ownerID, Actor.steamID()),
|
||||
isNull(steamLibraryTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.innerJoin(
|
||||
baseGamesTable,
|
||||
eq(baseGamesTable.id, steamLibraryTable.baseGameID),
|
||||
)
|
||||
.leftJoin(
|
||||
gamesTable,
|
||||
eq(gamesTable.baseGameID, baseGamesTable.id),
|
||||
)
|
||||
.leftJoin(
|
||||
categoriesTable,
|
||||
and(
|
||||
eq(categoriesTable.slug, gamesTable.categorySlug),
|
||||
eq(categoriesTable.type, gamesTable.categoryType),
|
||||
)
|
||||
)
|
||||
// Joining imagesTable 1-N with gamesTable multiplies rows; the subsequent Game.serialize has to uniqueBy to undo this.
|
||||
// For large libraries with many screenshots the Cartesian effect can significantly bloat the result and network payload.
|
||||
// One option is to aggregate the images in SQL before joining to keep exactly one row per game:
|
||||
// .leftJoin(
|
||||
// sql<typeof imagesTable.$inferSelect[]>`(SELECT * FROM images WHERE base_game_id = ${gamesTable.baseGameID} AND time_deleted IS NULL ORDER BY type, position)`.as("images"),
|
||||
// sql`TRUE`
|
||||
// )
|
||||
.leftJoin(
|
||||
imagesTable,
|
||||
and(
|
||||
eq(imagesTable.baseGameID, gamesTable.baseGameID),
|
||||
isNull(imagesTable.timeDeleted),
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
.then(rows => Game.serialize(rows))
|
||||
)
|
||||
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { timestamps, utc, } from "../drizzle/types";
|
||||
import { steamTable } from "../steam/steam.sql";
|
||||
import { baseGamesTable } from "../base-game/base-game.sql";
|
||||
import { boolean, index, integer, pgTable, primaryKey, varchar, } from "drizzle-orm/pg-core";
|
||||
|
||||
export const steamLibraryTable = pgTable(
|
||||
"game_libraries",
|
||||
{
|
||||
...timestamps,
|
||||
baseGameID: varchar("base_game_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => baseGamesTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
ownerID: varchar("owner_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => steamTable.id, {
|
||||
onDelete: "cascade"
|
||||
}),
|
||||
timeAcquired: utc("time_acquired").notNull(),
|
||||
lastPlayed: utc("last_played").notNull(),
|
||||
totalPlaytime: integer("total_playtime").notNull(),
|
||||
isFamilyShared: boolean("is_family_shared").notNull()
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({
|
||||
columns: [table.baseGameID, table.ownerID]
|
||||
}),
|
||||
index("idx_game_libraries_owner_id").on(table.ownerID),
|
||||
],
|
||||
);
|
||||