Compare commits

...

No commits in common. "2292513efc48cb7067af7ed7b176de80ba4a9dc8" and "e670c571c7ab0908637809504148be72bba5b9b8" have entirely different histories.

67 changed files with 2748 additions and 1433 deletions

47
.gitignore vendored
View File

@ -1,24 +1,41 @@
# build output
dist/
# generated types
.astro/
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
node_modules/
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# logs
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-debug.log*
# environment variables
.env
.env.production
# env files (can opt-in for committing if needed)
.env*
# macOS-specific files
.DS_Store
# vercel
.vercel
# jetbrains setting folder
.idea/
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@ -1,6 +1,7 @@
{
"tabWidth": 2,
"singleQuote": true,
"semi": false,
"trailingComma": "es5"
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "all",
"singleQuote": true
}

View File

@ -1,4 +0,0 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored
View File

@ -1,11 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

View File

@ -1,40 +0,0 @@
// @ts-check
import { defineConfig } from 'astro/config'
import tailwind from '@astrojs/tailwind'
import icon from 'astro-icon'
import mdx from '@astrojs/mdx'
import sitemap from '@astrojs/sitemap'
import vercel from '@astrojs/vercel';
// https://astro.build/config
export default defineConfig({
site: 'https://solsynth.dev',
integrations: [
tailwind(),
icon(),
mdx(),
sitemap({
xslURL: '/sitemap.xsl',
i18n: { defaultLocale: 'en', locales: { en: 'en-US', 'zh-cn': 'zh-CN' } },
}),
],
prefetch: true,
i18n: {
locales: ['en', 'zh-cn'],
defaultLocale: 'en',
routing: {
fallbackType: 'rewrite',
prefixDefaultLocale: false,
},
},
adapter: vercel(),
})

BIN
bun.lockb

Binary file not shown.

16
eslint.config.mjs Normal file
View File

@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

31
next.config.ts Normal file
View File

@ -0,0 +1,31 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
/* config options here */
reactStrictMode: true,
generateBuildId: async () => {
return process.env.GIT_HASH ?? 'development'
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'raw.sn.solsynth.dev',
},
{
protocol: 'https',
hostname: 'api.sn.solsynth.dev',
},
],
},
async rewrites() {
return [
{
source: '/.well-known/:path*',
destination: '/api/well-known/:path*',
},
]
},
}
export default nextConfig

View File

@ -1,30 +1,48 @@
{
"name": "capital",
"type": "module",
"version": "0.0.1",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@astrojs/mdx": "^4.0.2",
"@astrojs/sitemap": "^3.2.1",
"@astrojs/tailwind": "^5.1.3",
"@astrojs/vercel": "^8.0.1",
"@iconify-json/material-symbols": "^1.2.10",
"@iconify-json/simple-icons": "^1.2.16",
"astro": "^5.0.5",
"astro-icon": "^1.1.4",
"astro-seo": "^0.8.4",
"marked": "^15.0.4",
"sanitize-html": "^2.13.1",
"tailwindcss": "^3.4.16"
"@emotion/cache": "^11.14.0",
"@emotion/react": "^11.14.0",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.14.0",
"@mui/icons-material": "^6.3.0",
"@mui/material": "^6.3.0",
"@mui/material-nextjs": "^6.3.1",
"@mui/x-charts": "^7.23.2",
"@tailwindcss/typography": "^0.5.15",
"animate.css": "^4.1.1",
"axios": "^1.7.9",
"axios-case-converter": "^1.1.1",
"cookies-next": "^5.0.2",
"next": "15.1.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1",
"remark-breaks": "^4.0.0",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"unified": "^11.0.5",
"zustand": "^5.0.2"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.15",
"@types/sanitize-html": "^2.13.0",
"daisyui": "^4.12.22"
"typescript": "^5",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"eslint": "^9",
"eslint-config-next": "15.1.3",
"@eslint/eslintrc": "^3"
}
}

8
postcss.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

View File

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

BIN
public/logo-w-padding.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

BIN
public/logo.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -1,4 +0,0 @@
User-agent: *
Allow: /
Sitemap: https://solsynth.dev/sitemap-index.xml

View File

@ -1,46 +0,0 @@
<svg id="livetype" xmlns="http://www.w3.org/2000/svg" width="119.66407" height="40" viewBox="0 0 119.66407 40">
<title>Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917</title>
<g>
<g>
<g>
<path d="M110.13477,0H9.53468c-.3667,0-.729,0-1.09473.002-.30615.002-.60986.00781-.91895.0127A13.21476,13.21476,0,0,0,5.5171.19141a6.66509,6.66509,0,0,0-1.90088.627A6.43779,6.43779,0,0,0,1.99757,1.99707,6.25844,6.25844,0,0,0,.81935,3.61816a6.60119,6.60119,0,0,0-.625,1.90332,12.993,12.993,0,0,0-.1792,2.002C.00587,7.83008.00489,8.1377,0,8.44434V31.5586c.00489.3105.00587.6113.01515.9219a12.99232,12.99232,0,0,0,.1792,2.0019,6.58756,6.58756,0,0,0,.625,1.9043A6.20778,6.20778,0,0,0,1.99757,38.001a6.27445,6.27445,0,0,0,1.61865,1.1787,6.70082,6.70082,0,0,0,1.90088.6308,13.45514,13.45514,0,0,0,2.0039.1768c.30909.0068.6128.0107.91895.0107C8.80567,40,9.168,40,9.53468,40H110.13477c.3594,0,.7246,0,1.084-.002.3047,0,.6172-.0039.9219-.0107a13.279,13.279,0,0,0,2-.1768,6.80432,6.80432,0,0,0,1.9082-.6308,6.27742,6.27742,0,0,0,1.6172-1.1787,6.39482,6.39482,0,0,0,1.1816-1.6143,6.60413,6.60413,0,0,0,.6191-1.9043,13.50643,13.50643,0,0,0,.1856-2.0019c.0039-.3106.0039-.6114.0039-.9219.0078-.3633.0078-.7246.0078-1.0938V9.53613c0-.36621,0-.72949-.0078-1.09179,0-.30664,0-.61426-.0039-.9209a13.5071,13.5071,0,0,0-.1856-2.002,6.6177,6.6177,0,0,0-.6191-1.90332,6.46619,6.46619,0,0,0-2.7988-2.7998,6.76754,6.76754,0,0,0-1.9082-.627,13.04394,13.04394,0,0,0-2-.17676c-.3047-.00488-.6172-.01074-.9219-.01269-.3594-.002-.7246-.002-1.084-.002Z" style="fill: #a6a6a6"/>
<path d="M8.44483,39.125c-.30468,0-.602-.0039-.90429-.0107a12.68714,12.68714,0,0,1-1.86914-.1631,5.88381,5.88381,0,0,1-1.65674-.5479,5.40573,5.40573,0,0,1-1.397-1.0166,5.32082,5.32082,0,0,1-1.02051-1.3965,5.72186,5.72186,0,0,1-.543-1.6572,12.41351,12.41351,0,0,1-.1665-1.875c-.00634-.2109-.01464-.9131-.01464-.9131V8.44434S.88185,7.75293.8877,7.5498a12.37039,12.37039,0,0,1,.16553-1.87207,5.7555,5.7555,0,0,1,.54346-1.6621A5.37349,5.37349,0,0,1,2.61183,2.61768,5.56543,5.56543,0,0,1,4.01417,1.59521a5.82309,5.82309,0,0,1,1.65332-.54394A12.58589,12.58589,0,0,1,7.543.88721L8.44532.875H111.21387l.9131.0127a12.38493,12.38493,0,0,1,1.8584.16259,5.93833,5.93833,0,0,1,1.6709.54785,5.59374,5.59374,0,0,1,2.415,2.41993,5.76267,5.76267,0,0,1,.5352,1.64892,12.995,12.995,0,0,1,.1738,1.88721c.0029.2832.0029.5874.0029.89014.0079.375.0079.73193.0079,1.09179V30.4648c0,.3633,0,.7178-.0079,1.0752,0,.3252,0,.6231-.0039.9297a12.73126,12.73126,0,0,1-.1709,1.8535,5.739,5.739,0,0,1-.54,1.67,5.48029,5.48029,0,0,1-1.0156,1.3857,5.4129,5.4129,0,0,1-1.3994,1.0225,5.86168,5.86168,0,0,1-1.668.5498,12.54218,12.54218,0,0,1-1.8692.1631c-.2929.0068-.5996.0107-.8974.0107l-1.084.002Z"/>
</g>
<g id="_Group_" data-name="&lt;Group&gt;">
<g id="_Group_2" data-name="&lt;Group&gt;">
<g id="_Group_3" data-name="&lt;Group&gt;">
<path id="_Path_" data-name="&lt;Path&gt;" d="M24.76888,20.30068a4.94881,4.94881,0,0,1,2.35656-4.15206,5.06566,5.06566,0,0,0-3.99116-2.15768c-1.67924-.17626-3.30719,1.00483-4.1629,1.00483-.87227,0-2.18977-.98733-3.6085-.95814a5.31529,5.31529,0,0,0-4.47292,2.72787c-1.934,3.34842-.49141,8.26947,1.3612,10.97608.9269,1.32535,2.01018,2.8058,3.42763,2.7533,1.38706-.05753,1.9051-.88448,3.5794-.88448,1.65876,0,2.14479.88448,3.591.8511,1.48838-.02416,2.42613-1.33124,3.32051-2.66914a10.962,10.962,0,0,0,1.51842-3.09251A4.78205,4.78205,0,0,1,24.76888,20.30068Z" style="fill: #fff"/>
<path id="_Path_2" data-name="&lt;Path&gt;" d="M22.03725,12.21089a4.87248,4.87248,0,0,0,1.11452-3.49062,4.95746,4.95746,0,0,0-3.20758,1.65961,4.63634,4.63634,0,0,0-1.14371,3.36139A4.09905,4.09905,0,0,0,22.03725,12.21089Z" style="fill: #fff"/>
</g>
</g>
<g>
<path d="M42.30227,27.13965h-4.7334l-1.13672,3.35645H34.42727l4.4834-12.418h2.083l4.4834,12.418H43.438ZM38.0591,25.59082h3.752l-1.84961-5.44727h-.05176Z" style="fill: #fff"/>
<path d="M55.15969,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H48.4302v1.50586h.03418a3.21162,3.21162,0,0,1,2.88281-1.60059C53.645,21.34766,55.15969,23.16406,55.15969,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C52.30227,29.01563,53.24953,27.81934,53.24953,25.96973Z" style="fill: #fff"/>
<path d="M65.12453,25.96973c0,2.81348-1.50586,4.62109-3.77832,4.62109a3.0693,3.0693,0,0,1-2.84863-1.584h-.043v4.48438h-1.8584V21.44238H58.395v1.50586h.03418A3.21162,3.21162,0,0,1,61.312,21.34766C63.60988,21.34766,65.12453,23.16406,65.12453,25.96973Zm-1.91016,0c0-1.833-.94727-3.03809-2.39258-3.03809-1.41992,0-2.375,1.23047-2.375,3.03809,0,1.82422.95508,3.0459,2.375,3.0459C62.26711,29.01563,63.21438,27.81934,63.21438,25.96973Z" style="fill: #fff"/>
<path d="M71.71047,27.03613c.1377,1.23145,1.334,2.04,2.96875,2.04,1.56641,0,2.69336-.80859,2.69336-1.91895,0-.96387-.67969-1.541-2.28906-1.93652l-1.60937-.3877c-2.28027-.55078-3.33887-1.61719-3.33887-3.34766,0-2.14258,1.86719-3.61426,4.51855-3.61426,2.624,0,4.42285,1.47168,4.4834,3.61426h-1.876c-.1123-1.23926-1.13672-1.9873-2.63379-1.9873s-2.52148.75684-2.52148,1.8584c0,.87793.6543,1.39453,2.25488,1.79l1.36816.33594c2.54785.60254,3.60645,1.626,3.60645,3.44238,0,2.32324-1.85059,3.77832-4.79395,3.77832-2.75391,0-4.61328-1.4209-4.7334-3.667Z" style="fill: #fff"/>
<path d="M83.34621,19.2998v2.14258h1.72168v1.47168H83.34621v4.99121c0,.77539.34473,1.13672,1.10156,1.13672a5.80752,5.80752,0,0,0,.61133-.043v1.46289a5.10351,5.10351,0,0,1-1.03223.08594c-1.833,0-2.54785-.68848-2.54785-2.44434V22.91406H80.16262V21.44238H81.479V19.2998Z" style="fill: #fff"/>
<path d="M86.065,25.96973c0-2.84863,1.67773-4.63867,4.29395-4.63867,2.625,0,4.29492,1.79,4.29492,4.63867,0,2.85645-1.66113,4.63867-4.29492,4.63867C87.72609,30.6084,86.065,28.82617,86.065,25.96973Zm6.69531,0c0-1.9541-.89551-3.10742-2.40137-3.10742s-2.40039,1.16211-2.40039,3.10742c0,1.96191.89453,3.10645,2.40039,3.10645S92.76027,27.93164,92.76027,25.96973Z" style="fill: #fff"/>
<path d="M96.18606,21.44238h1.77246v1.541h.043a2.1594,2.1594,0,0,1,2.17773-1.63574,2.86616,2.86616,0,0,1,.63672.06934v1.73828a2.59794,2.59794,0,0,0-.835-.1123,1.87264,1.87264,0,0,0-1.93652,2.083v5.37012h-1.8584Z" style="fill: #fff"/>
<path d="M109.3843,27.83691c-.25,1.64355-1.85059,2.77148-3.89844,2.77148-2.63379,0-4.26855-1.76465-4.26855-4.5957,0-2.83984,1.64355-4.68164,4.19043-4.68164,2.50488,0,4.08008,1.7207,4.08008,4.46582v.63672h-6.39453v.1123a2.358,2.358,0,0,0,2.43555,2.56445,2.04834,2.04834,0,0,0,2.09082-1.27344Zm-6.28223-2.70215h4.52637a2.1773,2.1773,0,0,0-2.2207-2.29785A2.292,2.292,0,0,0,103.10207,25.13477Z" style="fill: #fff"/>
</g>
</g>
</g>
<g id="_Group_4" data-name="&lt;Group&gt;">
<g>
<path d="M37.82619,8.731a2.63964,2.63964,0,0,1,2.80762,2.96484c0,1.90625-1.03027,3.002-2.80762,3.002H35.67092V8.731Zm-1.22852,5.123h1.125a1.87588,1.87588,0,0,0,1.96777-2.146,1.881,1.881,0,0,0-1.96777-2.13379h-1.125Z" style="fill: #fff"/>
<path d="M41.68068,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C44.57522,13.99463,45.01369,13.42432,45.01369,12.44434Z" style="fill: #fff"/>
<path d="M51.57326,14.69775h-.92187l-.93066-3.31641h-.07031l-.92676,3.31641h-.91309l-1.24121-4.50293h.90137l.80664,3.436h.06641l.92578-3.436h.85254l.92578,3.436h.07031l.80273-3.436h.88867Z" style="fill: #fff"/>
<path d="M53.85354,10.19482H54.709v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915h-.88867V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M59.09377,8.437h.88867v6.26074h-.88867Z" style="fill: #fff"/>
<path d="M61.21779,12.44434a2.13346,2.13346,0,1,1,4.24756,0,2.1338,2.1338,0,1,1-4.24756,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C64.11232,13.99463,64.5508,13.42432,64.5508,12.44434Z" style="fill: #fff"/>
<path d="M66.4009,13.42432c0-.81055.60352-1.27783,1.6748-1.34424l1.21973-.07031v-.38867c0-.47559-.31445-.74414-.92187-.74414-.49609,0-.83984.18213-.93848.50049h-.86035c.09082-.77344.81836-1.26953,1.83984-1.26953,1.12891,0,1.76563.562,1.76563,1.51318v3.07666h-.85547v-.63281h-.07031a1.515,1.515,0,0,1-1.35254.707A1.36026,1.36026,0,0,1,66.4009,13.42432Zm2.89453-.38477v-.37646l-1.09961.07031c-.62012.0415-.90137.25244-.90137.64941,0,.40527.35156.64111.835.64111A1.0615,1.0615,0,0,0,69.29543,13.03955Z" style="fill: #fff"/>
<path d="M71.34816,12.44434c0-1.42285.73145-2.32422,1.86914-2.32422a1.484,1.484,0,0,1,1.38086.79h.06641V8.437h.88867v6.26074h-.85156v-.71143h-.07031a1.56284,1.56284,0,0,1-1.41406.78564C72.0718,14.772,71.34816,13.87061,71.34816,12.44434Zm.918,0c0,.95508.4502,1.52979,1.20313,1.52979.749,0,1.21191-.583,1.21191-1.52588,0-.93848-.46777-1.52979-1.21191-1.52979C72.72121,10.91846,72.26613,11.49707,72.26613,12.44434Z" style="fill: #fff"/>
<path d="M79.23,12.44434a2.13323,2.13323,0,1,1,4.24707,0,2.13358,2.13358,0,1,1-4.24707,0Zm3.333,0c0-.97607-.43848-1.54687-1.208-1.54687-.77246,0-1.207.5708-1.207,1.54688,0,.98389.43457,1.55029,1.207,1.55029C82.12453,13.99463,82.563,13.42432,82.563,12.44434Z" style="fill: #fff"/>
<path d="M84.66945,10.19482h.85547v.71533h.06641a1.348,1.348,0,0,1,1.34375-.80225,1.46456,1.46456,0,0,1,1.55859,1.6748v2.915H87.605V12.00586c0-.72363-.31445-1.0835-.97168-1.0835a1.03294,1.03294,0,0,0-1.0752,1.14111v2.63428h-.88867Z" style="fill: #fff"/>
<path d="M93.51516,9.07373v1.1416h.97559v.74854h-.97559V13.2793c0,.47168.19434.67822.63672.67822a2.96657,2.96657,0,0,0,.33887-.02051v.74023a2.9155,2.9155,0,0,1-.4834.04541c-.98828,0-1.38184-.34766-1.38184-1.21582v-2.543h-.71484v-.74854h.71484V9.07373Z" style="fill: #fff"/>
<path d="M95.70461,8.437h.88086v2.48145h.07031a1.3856,1.3856,0,0,1,1.373-.80664,1.48339,1.48339,0,0,1,1.55078,1.67871v2.90723H98.69v-2.688c0-.71924-.335-1.0835-.96289-1.0835a1.05194,1.05194,0,0,0-1.13379,1.1416v2.62988h-.88867Z" style="fill: #fff"/>
<path d="M104.76125,13.48193a1.828,1.828,0,0,1-1.95117,1.30273A2.04531,2.04531,0,0,1,100.73,12.46045a2.07685,2.07685,0,0,1,2.07617-2.35254c1.25293,0,2.00879.856,2.00879,2.27V12.688h-3.17969v.0498a1.1902,1.1902,0,0,0,1.19922,1.29,1.07934,1.07934,0,0,0,1.07129-.5459Zm-3.126-1.45117h2.27441a1.08647,1.08647,0,0,0-1.1084-1.1665A1.15162,1.15162,0,0,0,101.63527,12.03076Z" style="fill: #fff"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

View File

Before

Width:  |  Height:  |  Size: 696 KiB

After

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@ -1,29 +0,0 @@
---
import { getAttachmentUrl } from '../scripts/attachment'
interface Props {
data: any
}
const { data: attachment } = Astro.props
---
{
attachment.mimetype.startsWith('image') ? (
<a href={getAttachmentUrl(attachment.rid)} target="_blank">
<img
src={getAttachmentUrl(attachment.rid)}
alt={attachment.alt}
class="rounded-lg"
/>
</a>
) : attachment.mimetype.startsWith('video') ? (
<video src={getAttachmentUrl(attachment.rid)} controls class="rounded-lg" />
) : attachment.mimetype.startsWith('audio') ? (
<audio src={getAttachmentUrl(attachment.rid)} controls class="rounded-lg" />
) : (
<a href={getAttachmentUrl(attachment.rid)} target="_blank">
Unable to preview, but you can open it in your broswer.
</a>
)
}

View File

@ -0,0 +1,90 @@
import { useUserStore } from '@/services/user'
import { AppBar, AppBarProps, Avatar, IconButton, Toolbar, Typography, useScrollTrigger, useTheme } from '@mui/material'
import { getAttachmentUrl } from '@/services/network'
import MenuIcon from '@mui/icons-material/Menu'
import AccountCircle from '@mui/icons-material/AccountCircle'
import Link from 'next/link'
import React, { useState } from 'react'
import { CapDrawer } from './CapDrawer'
interface AppBarScrollProps {
elevation?: number
children?: React.ReactElement<{ elevation?: number } & AppBarProps>
}
function AppBarScroll(props: AppBarScrollProps) {
if (typeof window === 'undefined') return props.children
const trigger = useScrollTrigger({
disableHysteresis: true,
threshold: 0,
target: window,
})
const commonStyle = {
transition: 'all',
transitionDuration: '300ms',
}
return props.children
? React.cloneElement(props.children, {
elevation: trigger ? props.elevation : 0,
sx: trigger ? { borderBottom: 1, borderColor: 'divider', ...commonStyle } : { ...commonStyle },
})
: null
}
export function CapAppBar() {
const userStore = useUserStore()
const theme = useTheme()
const [open, setOpen] = useState(false)
const drawerWidth = 280
return (
<>
<CapDrawer width={drawerWidth} open={open} onClose={() => setOpen(false)} />
<AppBarScroll elevation={0}>
<AppBar position="sticky" elevation={0} color="transparent" className="backdrop-blur-md">
<Toolbar>
<IconButton
size="large"
edge="start"
color="inherit"
aria-label="menu"
sx={{ mr: 2 }}
onClick={() => setOpen(true)}
>
<MenuIcon />
</IconButton>
<Link href="/" passHref style={{ flexGrow: 1 }}>
<Typography variant="h6" component="div">
Capital
</Typography>
</Link>
<Link href={userStore.account ? '/users/me' : '/auth/login'} passHref>
<IconButton
size="large"
aria-label="account of current user"
aria-controls="primary-search-account-menu"
aria-haspopup="true"
color="inherit"
>
{userStore.account ? (
<Avatar sx={{ backgroundColor: 'transparent' }} src={getAttachmentUrl(userStore.account.avatar)} />
) : (
<Avatar sx={{ backgroundColor: 'transparent' }}>
<AccountCircle sx={{ color: theme.palette.text.primary }} />
</Avatar>
)}
</IconButton>
</Link>
</Toolbar>
</AppBar>
</AppBarScroll>
</>
)
}

View File

@ -0,0 +1,96 @@
import {
Box,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Divider,
Drawer,
Toolbar,
Typography,
} from '@mui/material'
import { JSX } from 'react'
import Image from 'next/image'
import FeedIcon from '@mui/icons-material/Feed'
import PhotoLibraryIcon from '@mui/icons-material/PhotoLibrary'
import PolicyIcon from '@mui/icons-material/Policy'
import Link from 'next/link'
interface NavLink {
title: string
icon: JSX.Element
href: string
}
export function CapDrawer({ width, open, onClose }: { width: number; open: boolean; onClose: () => void }) {
const functionLinks: NavLink[] = [
{
title: 'Posts',
icon: <FeedIcon />,
href: '/posts',
},
{
title: 'Gallery',
icon: <PhotoLibraryIcon />,
href: '/attachments',
},
]
const additionalLinks: NavLink[] = [
{
title: 'Terms & Conditions',
icon: <PolicyIcon />,
href: '/terms',
},
]
return (
<Drawer open={open} onClose={onClose}>
<Box sx={{ width: width }} role="presentation" onClick={onClose}>
<Toolbar style={{ padding: 0 }}>
<Box display="flex" gap={2} sx={{ mx: 2 }}>
<Image src="/logo.png" width={28} height={28} alt="company logo" style={{ objectFit: 'contain' }} />
<Box display="flex" flexDirection="column" justifyContent="center">
<Typography variant="body2" component="h2" fontWeight="bold" lineHeight={1.4}>
Solsynth LLC
</Typography>
<Typography variant="caption" component="h3" lineHeight={1} fontFamily="monospace">
Capital
</Typography>
</Box>
</Box>
</Toolbar>
<Divider />
<List>
{functionLinks.map((l) => (
<Link passHref href={l.href} key={l.href}>
<ListItem disablePadding>
<ListItemButton>
<ListItemIcon>{l.icon}</ListItemIcon>
<ListItemText primary={l.title} />
</ListItemButton>
</ListItem>
</Link>
))}
</List>
<Divider />
<List dense>
{additionalLinks.map((l) => (
<Link passHref href={l.href} key={l.href}>
<ListItem disablePadding>
<ListItemButton>
<ListItemIcon>{l.icon}</ListItemIcon>
<ListItemText primary={l.title} />
</ListItemButton>
</ListItem>
</Link>
))}
</List>
</Box>
</Drawer>
)
}

View File

@ -0,0 +1,36 @@
import { SnAttachment } from '@/services/attachment'
import { getAttachmentUrl } from '@/services/network'
import { QuestionMark } from '@mui/icons-material'
import { Link, Paper, Typography } from '@mui/material'
import { ComponentProps } from 'react'
export function AttachmentItem({ item, ...rest }: { item: SnAttachment } & ComponentProps<'div'>) {
switch (item.mimetype.split('/')[0]) {
case 'image':
return (
<Paper {...rest}>
<img src={getAttachmentUrl(item.rid)} alt={item.alt} style={{ objectFit: 'cover', borderRadius: '8px' }} />
</Paper>
)
case 'video':
return (
<Paper {...rest}>
<video src={getAttachmentUrl(item.rid)} controls style={{ borderRadius: '8px' }} />
</Paper>
)
default:
return (
<Paper sx={{ width: '100%', height: '100%', p: 5, textAlign: 'center' }} {...rest}>
<QuestionMark sx={{ mb: 2 }} />
<Typography>Unknown</Typography>
<Typography gutterBottom>{item.name}</Typography>
<Typography fontFamily="monospace" gutterBottom>
{item.mimetype}
</Typography>
<Link href={getAttachmentUrl(item.rid)} target="_blank" rel="noreferrer" fontSize={13}>
Open in browser
</Link>
</Paper>
)
}
}

View File

@ -0,0 +1,83 @@
'use client'
import { SnAuthFactor, SnAuthResult, SnAuthTicket } from '@/services/auth'
import { sni } from '@/services/network'
import { ArrowForward } from '@mui/icons-material'
import { Collapse, Alert, Box, TextField, Button } from '@mui/material'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import ErrorIcon from '@mui/icons-material/Error'
import { setCookie } from 'cookies-next/client'
export interface SnLoginCheckpointForm {
password: string
}
export function SnLoginCheckpoint({
ticket,
factor,
onNext,
}: {
ticket: SnAuthTicket
factor: SnAuthFactor
onNext: (val: SnAuthTicket, done: boolean) => void
}) {
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState<boolean>(false)
const { handleSubmit, register } = useForm<SnLoginCheckpointForm>()
async function onSubmit(data: any) {
try {
setLoading(true)
const resp = await sni.patch<SnAuthResult>('/cgi/id/auth', {
ticket_id: ticket.id,
factor_id: factor.id,
code: data.password,
})
if (resp.data.isFinished) {
const tokenResp = await sni.post('/cgi/id/auth/token', {
grant_type: 'grant_token',
code: resp.data.ticket.grantToken!,
})
const atk: string = tokenResp.data['accessToken']
const rtk: string = tokenResp.data['refreshToken']
setCookie('nex_user_atk', atk, { path: '/', maxAge: 2592000 })
setCookie('nex_user_rtk', rtk, { path: '/', maxAge: 2592000 })
console.log('[Authenticator] User has been logged in. Result atk: ', atk)
}
onNext(resp.data.ticket, resp.data.isFinished)
} catch (err: any) {
setError(err.toString())
} finally {
setLoading(false)
}
}
return (
<>
<Collapse in={!!error} sx={{ width: '100%' }}>
<Alert sx={{ mb: 4 }} icon={<ErrorIcon fontSize="inherit" />} severity="error">
{error}
</Alert>
</Collapse>
<form onSubmit={handleSubmit(onSubmit)}>
<Box sx={{ display: 'flex', flexDirection: 'column', width: '100%', gap: 2, textAlign: 'center' }}>
<TextField
label={factor.type == 0 ? 'Password' : 'Verification code'}
type="password"
{...register('password', { required: true })}
/>
<Button variant="contained" endIcon={<ArrowForward />} disabled={loading} type="submit">
Next
</Button>
</Box>
</form>
</>
)
}

View File

@ -0,0 +1,68 @@
'use client'
import { SnAuthFactor, SnAuthTicket } from '@/services/auth'
import { sni } from '@/services/network'
import { Collapse, Alert, Box, Button, Typography, ButtonGroup } from '@mui/material'
import { useState } from 'react'
import ErrorIcon from '@mui/icons-material/Error'
import PasswordIcon from '@mui/icons-material/Password'
import EmailIcon from '@mui/icons-material/Email'
export function SnLoginRouter({
ticket,
factorList,
onNext,
}: {
ticket: SnAuthTicket
factorList: SnAuthFactor[]
onNext: (val: SnAuthFactor) => void
}) {
const factorTypeIcons = [<PasswordIcon />, <EmailIcon />]
const factorTypeLabels = ['Password', 'Email verification code']
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState<boolean>(false)
async function onSubmit(factor: SnAuthFactor) {
try {
setLoading(true)
await sni.post('/cgi/id/auth/factors/' + factor.id)
onNext(factor)
} catch (err: any) {
setError(err.toString())
} finally {
setLoading(false)
}
}
return (
<>
<Collapse in={!!error} sx={{ width: 320 }}>
<Alert sx={{ mb: 4 }} icon={<ErrorIcon fontSize="inherit" />} severity="error">
{error}
</Alert>
</Collapse>
<Box sx={{ display: 'flex', flexDirection: 'column', width: '100%', gap: 2, textAlign: 'center' }}>
<ButtonGroup orientation="vertical" aria-label="Vertical button group">
{factorList.map((factor) => (
<Button
sx={{ py: 1 }}
key={factor.id}
onClick={() => onSubmit(factor)}
disabled={loading || ticket.factorTrail?.includes(factor.id)}
startIcon={factorTypeIcons[factor.type]}
>
{factorTypeLabels[factor.type]}
</Button>
))}
</ButtonGroup>
<Typography variant="caption" sx={{ opacity: 0.75, mx: 2 }}>
{ticket.stepRemain} step(s) left
</Typography>
</Box>
</>
)
}

View File

@ -0,0 +1,74 @@
'use client'
import { useState } from 'react'
import { sni } from '@/services/network'
import { ArrowForward } from '@mui/icons-material'
import { Alert, Box, Button, Collapse, Link, TextField, Typography } from '@mui/material'
import { SnAuthFactor, SnAuthResult, SnAuthTicket } from '@/services/auth'
import { useForm } from 'react-hook-form'
import NextLink from 'next/link'
import ErrorIcon from '@mui/icons-material/Error'
export type SnLoginStartForm = {
username: string
}
export function SnLoginStart({ onNext }: { onNext: (val: SnAuthTicket, fcs: SnAuthFactor[]) => void }) {
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState<boolean>(false)
const { handleSubmit, register } = useForm<SnLoginStartForm>()
async function onSubmit(data: any) {
try {
setLoading(true)
const resp = await sni.post<SnAuthResult>('/cgi/id/auth', data)
const factorResp = await sni.get<SnAuthFactor[]>('/cgi/id/auth/factors', {
params: {
ticketId: resp.data.ticket.id,
},
})
onNext(resp.data.ticket, factorResp.data)
} catch (err: any) {
setError(err.toString())
} finally {
setLoading(false)
}
}
return (
<>
<Collapse in={!!error} sx={{ width: '100%' }}>
<Alert sx={{ mb: 4 }} icon={<ErrorIcon fontSize="inherit" />} severity="error">
{error}
</Alert>
</Collapse>
<form onSubmit={handleSubmit(onSubmit)}>
<Box sx={{ display: 'flex', flexDirection: 'column', width: '100%', gap: 2, textAlign: 'center' }}>
<TextField
label="Username"
helperText="You can also use email address and phone number"
{...register('username', { required: true })}
/>
<Button variant="contained" endIcon={<ArrowForward />} disabled={loading} type="submit">
Next
</Button>
<Typography variant="caption" sx={{ opacity: 0.75, mx: 2 }}>
By continuing means you agree to our{' '}
<NextLink href="/terms/privacy-policy" passHref>
<Link component="span">Privacy Policy</Link>
</NextLink>{' '}
and{' '}
<NextLink href="/terms/user-agreements" passHref>
<Link component="span">User Agreements</Link>
</NextLink>
</Typography>
</Box>
</form>
</>
)
}

View File

@ -1,121 +0,0 @@
---
import { Image } from 'astro:assets'
import { version } from '../../package.json'
import CompanyLogo from '../assets/images/company-logo.png'
import { getRelativeLocaleUrl } from 'astro:i18n'
interface Props {
title?: string
trailingTitle?: string
}
let { title, trailingTitle } = Astro.props
if (!trailingTitle) trailingTitle = 'Solsynth LLC'
console.log(title ? `${title} | ${trailingTitle}` : trailingTitle)
const locale = Astro.currentLocale ?? 'en'
---
<!doctype html>
<html lang={Astro.currentLocale ?? 'en'}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="sitemap" href="/sitemap-index.xml" />
<title>{title ? `${title} | ${trailingTitle}` : trailingTitle}</title>
</head>
<body>
<div class="navbar backdrop-blur fixed top-0 left-0 right-0 z-10">
<div class="px-5 flex-1">
<div class="flex-none">
<a class="btn btn-ghost text-xl flex items-center gap-2" href="/">
<Image
src={CompanyLogo}
alt="company logo"
class="h-8 w-8 p-1 bg-white rounded-lg shadow-sm"
/>
<span>Solsynth</span>
</a>
</div>
<div class="flex-1 flex justify-end">
<ul class="menu menu-horizontal px-1">
<li><a href="/posts">Explore</a></li>
</ul>
</div>
</div>
</div>
<main class="mt-[68px]">
<slot />
</main>
<footer class="bg-neutral text-neutral-content p-10 mt-32">
<div class="container mx-auto footer">
<aside>
<Image
src={CompanyLogo}
alt="company logo"
class="h-12 w-12 p-1 bg-white rounded-lg shadow-sm"
/>
<div class="flex flex-col">
<span class="font-bold text-lg">Solsynth LLC</span>
Building wonderful software since 2019.
<span class="font-mono text-xs mt-3">Powered by RoadSign v2</span>
<span class="font-mono text-xs">Capital v{version}</span>
<a href="https://status.solsynth.dev" class="mt-4">
<img
src="https://uptime.betterstack.com/status-badges/v1/monitor/1ki5r.svg"
/>
</a>
</div>
</aside>
<nav>
<h6 class="footer-title">Services</h6>
<a class="link link-hover" href="https://sn.solsynth.dev"> Solian </a>
<a class="link link-hover" href="https://files.solsynth.dev">
Solarfiles
</a>
<a class="link link-hover" href="https://git.solsynth.dev">
Solargit
</a>
</nav>
<nav>
<h6 class="footer-title">Legal</h6>
<a
class="link link-hover"
href={getRelativeLocaleUrl(locale, '/terms/user-agreements')}
>
User Agreements
</a>
<a
class="link link-hover"
href={getRelativeLocaleUrl(locale, '/terms/privacy-policy')}
>
Privacy Policy
</a>
<a
class="link link-hover"
href={getRelativeLocaleUrl(locale, '/terms')}
>
All Terms & Conditions
</a>
</nav>
</div>
</footer>
</body>
</html>
<style>
html,
body {
margin: 0;
width: 100%;
height: 100%;
}
</style>

68
src/pages/_app.tsx Normal file
View File

@ -0,0 +1,68 @@
import '@/styles/globals.css'
import type { AppProps } from 'next/app'
import { Box, createTheme, CssBaseline, ThemeProvider } from '@mui/material'
import { Roboto } from 'next/font/google'
import { CapAppBar } from '@/components/CapAppBar'
import { useUserStore } from '@/services/user'
import { useEffect } from 'react'
import Head from 'next/head'
const fontRoboto = Roboto({
subsets: ['latin'],
weight: ['400', '500', '700'],
display: 'swap',
})
const siteTheme = createTheme({
cssVariables: true,
// colorSchemes: {
// dark: true,
// },
palette: {
mode: 'light',
primary: {
main: '#3949ab',
},
secondary: {
main: '#1e88e5',
},
},
})
export default function App({ Component, pageProps }: AppProps) {
const userStore = useUserStore()
useEffect(() => {
userStore.fetchUser()
}, [])
const title = pageProps.title ? `${pageProps.title} | Solsynth LLC` : 'Solsynth LLC'
return (
<>
<style jsx global>{`
html {
font-family: ${fontRoboto.style.fontFamily};
scroll-behavior: smooth;
}
`}</style>
<Head>
<title>{title}</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.png" type="image/png" />
<link rel="apple-touch-icon" href="/apple-icon.png" type="image/png" />
</Head>
<ThemeProvider theme={siteTheme}>
<CssBaseline />
<CapAppBar />
<Box sx={{ minHeight: 'calc(100vh - 64px)' }}>
<Component {...pageProps} />
</Box>
</ThemeProvider>
</>
)
}

28
src/pages/_document.tsx Normal file
View File

@ -0,0 +1,28 @@
import {
AppCacheProvider,
DocumentHeadTags,
DocumentHeadTagsProps,
documentGetInitialProps,
} from '@mui/material-nextjs/v15-pagesRouter'
import { Html, Head, Main, NextScript, DocumentContext, DocumentProps } from 'next/document'
export default function Document(props: DocumentProps & DocumentHeadTagsProps) {
return (
<AppCacheProvider {...props}>
<Html lang="en">
<Head>
<DocumentHeadTags {...props} />
</Head>
<body className="antialiased">
<Main />
<NextScript />
</body>
</Html>
</AppCacheProvider>
)
}
Document.getInitialProps = async (ctx: DocumentContext) => {
const finalProps = await documentGetInitialProps(ctx)
return finalProps
}

View File

@ -0,0 +1,10 @@
import axios from 'axios'
import { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(_: NextApiRequest, res: NextApiResponse) {
const solarNetworkApi = 'https://api.sn.solsynth.dev'
const resp = await axios.get(solarNetworkApi + '/cgi/id/well-known/jwks')
res.status(200).json(resp.data)
}

View File

@ -0,0 +1,23 @@
import axios from 'axios'
import { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(_: NextApiRequest, res: NextApiResponse) {
const siteUrl = 'https://solsynth.dev'
const solarNetworkApi = 'https://api.sn.solsynth.dev'
const resp = await axios.get(solarNetworkApi + '/cgi/id/well-known/openid-configuration')
const out: Record<string, any> = resp.data
out['authorization_endpoint'] = siteUrl + '/auth/authorize'
out['jwks_uri'] = siteUrl + '/.well-known/jwks'
for (let [k, v] of Object.entries(out)) {
if (typeof v === 'string') {
if (v.startsWith('https://id.solsynth.dev/api')) {
out[k] = v.replace('https://id.solsynth.dev/api', solarNetworkApi + '/cgi/id')
}
}
}
res.status(200).json(out)
}

View File

@ -0,0 +1,125 @@
import { sni } from '@/services/network'
import { Container, Box, Typography, Alert, Collapse, Button, CircularProgress, Card, CardContent } from '@mui/material'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import ErrorIcon from '@mui/icons-material/Error'
import CloseIcon from '@mui/icons-material/Close'
import CheckIcon from '@mui/icons-material/Check'
import { SnAuthTicket } from '@/services/auth'
export default function AccountAuthorize() {
const router = useRouter()
const [thirdClient, setThirdClient] = useState<any>(null)
const [error, setError] = useState<string | null>(null)
const [reverting, setReverting] = useState(false)
const [busy, setBusy] = useState(false)
function doCallback(ticket: SnAuthTicket) {
const url = `${router.query['redirect_uri']}?code=${ticket.grantToken}&state=${router.query['state']}`
window.open(url, '_self')
}
async function fetch() {
try {
setReverting(true)
const resp = await sni.get<{ ticket: SnAuthTicket; client: any }>(
'/cgi/id/auth/o/authorize' + window.location.search,
{},
)
if (resp.data.ticket) {
return doCallback(resp.data.ticket)
}
setThirdClient(resp.data.client)
} catch (err: any) {
setError(err.toString())
} finally {
setReverting(false)
}
}
useEffect(() => {
fetch()
}, [])
async function confirm() {
try {
setBusy(true)
const resp = await sni.post<{ ticket: SnAuthTicket }>('/cgi/id/auth/o/authorize' + window.location.search)
return doCallback(resp.data.ticket)
} catch (err: any) {
setError(err.toString())
} finally {
setBusy(false)
}
}
function decline() {
if (window.history.length > 0) {
window.history.back()
} else {
window.close()
}
}
return (
<Container
sx={{
display: 'grid',
placeItems: 'center',
height: 'calc(100vh - 64px)',
textAlign: 'center',
}}
maxWidth="xs"
>
<Box sx={{ width: '100%' }}>
<Typography variant="h5" component="h1">
Connect with Solarpass
</Typography>
<Typography variant="subtitle2" component="h2">
Connect third-party services with Solar Network
</Typography>
<Collapse in={!!error} sx={{ width: '100%' }}>
<Alert sx={{ mt: 4 }} icon={<ErrorIcon fontSize="inherit" />} severity="error">
{error}
</Alert>
</Collapse>
{reverting && (
<Box sx={{ mt: 3 }}>
<CircularProgress />
</Box>
)}
{!reverting && (
<Box sx={{ mt: 3 }}>
<Card variant="outlined" sx={{ width: '100%' }}>
<CardContent sx={{ textAlign: 'left', px: 2.5 }}>
<Typography variant="h6">{thirdClient?.name}</Typography>
<Typography variant="body2">{thirdClient?.description}</Typography>
</CardContent>
</Card>
<Box display="flex" justifyContent="space-between">
<Button sx={{ mt: 3 }} startIcon={<CloseIcon />} onClick={() => decline()} disabled={busy} color="error">
Decline
</Button>
<Button
sx={{ mt: 3 }}
startIcon={<CheckIcon />}
onClick={() => confirm()}
disabled={busy}
color="success"
>
Approve
</Button>
</Box>
</Box>
)}
</Box>
</Container>
)
}

103
src/pages/auth/login.tsx Normal file
View File

@ -0,0 +1,103 @@
import { SnLoginCheckpoint } from '@/components/auth/SnLoginCheckpoint'
import { SnLoginRouter } from '@/components/auth/SnLoginRouter'
import { SnLoginStart } from '@/components/auth/SnLoginStart'
import { SnAuthFactor, SnAuthTicket } from '@/services/auth'
import { useUserStore } from '@/services/user'
import { Box, Container, Typography } from '@mui/material'
import { useRouter } from 'next/router'
import { useState } from 'react'
export default function Login() {
const [period, setPeriod] = useState<number>(0)
const [ticket, setTicket] = useState<SnAuthTicket | null>(null)
const [factorList, setFactorList] = useState<SnAuthFactor[]>([])
const [factor, setFactor] = useState<SnAuthFactor | null>(null)
const router = useRouter()
const userStore = useUserStore()
function doCallback() {
if (router.query['redirect_url']) {
let redirectUrl: string
if (Array.isArray(router.query['redirect_url'])) {
redirectUrl = router.query['redirect_url'][0]
} else {
redirectUrl = router.query['redirect_url'].toString()
}
if (redirectUrl.startsWith('/')) {
router.push(redirectUrl)
} else {
window.open(redirectUrl, '_self')
}
return
}
router.push('/users/me')
}
function renderForm() {
switch (period) {
case 1:
return (
<SnLoginRouter
ticket={ticket!}
factorList={factorList}
onNext={(val) => {
setPeriod(period + 1)
setFactor(val)
}}
/>
)
case 2:
return (
<SnLoginCheckpoint
ticket={ticket!}
factor={factor!}
onNext={(val, done) => {
if (!done) {
setTicket(val)
setPeriod(1)
return
}
userStore.fetchUser()
doCallback()
}}
/>
)
default:
return (
<SnLoginStart
onNext={(val, fcs) => {
setPeriod(period + 1)
setTicket(val)
setFactorList(fcs)
}}
/>
)
}
}
return (
<Container
sx={{
display: 'grid',
placeItems: 'center',
height: 'calc(100vh - 64px)',
textAlign: 'center',
}}
maxWidth="xs"
>
<Box sx={{ width: '100%' }}>
<Typography variant="h5" component="h1">
Login
</Typography>
<Typography variant="subtitle2" component="h2">
Login via Solarpass
</Typography>
<Box sx={{ mt: 3 }}>{renderForm()}</Box>
</Box>
</Container>
)
}

View File

@ -0,0 +1,71 @@
import { sni } from '@/services/network'
import { Container, Box, Typography, CircularProgress, Alert, Collapse } from '@mui/material'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import ErrorIcon from '@mui/icons-material/Error'
import 'animate.css'
export default function AccountConfirm() {
const router = useRouter()
const [error, setError] = useState<string | null>(null)
async function confirm() {
try {
await sni.post('/cgi/id/users/me/confirm', {
code: router.query['code'] as string,
})
router.push('/')
} catch (err: any) {
setError(err.toString())
}
}
useEffect(() => {
confirm()
}, [])
return (
<Container
sx={{
display: 'grid',
placeItems: 'center',
height: 'calc(100vh - 64px)',
textAlign: 'center',
}}
maxWidth="xs"
>
<Box sx={{ width: '100%' }}>
<Typography variant="h5" component="h1">
Confirm Account
</Typography>
<Typography variant="subtitle2" component="h2">
Confirm your registeration on Solar Network
</Typography>
<Collapse in={!!error} sx={{ width: '100%' }}>
<Alert sx={{ mt: 4 }} icon={<ErrorIcon fontSize="inherit" />} severity="error">
{error}
</Alert>
</Collapse>
{!error && (
<Box sx={{ mt: 3 }}>
<CircularProgress />
<Typography
variant="body2"
sx={{ mt: 3 }}
className="animate__animated animate__flash animate__infinite"
style={{ '--animate-duration': '3s' } as any}
>
Hold on a moment, we're working on it...
</Typography>
</Box>
)}
</Box>
</Container>
)
}

View File

@ -0,0 +1,69 @@
import { sni } from '@/services/network'
import { Container, Box, Typography, Alert, Collapse, Button } from '@mui/material'
import { useRouter } from 'next/router'
import { useState } from 'react'
import ErrorIcon from '@mui/icons-material/Error'
export default function AccountDeletion() {
const router = useRouter()
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
async function confirm() {
try {
setBusy(true)
await sni.patch('/cgi/id/users/me/deletion', {
code: router.query['code'] as string,
})
router.push('/')
} catch (err: any) {
setError(err.toString())
} finally {
setBusy(false)
}
}
return (
<Container
sx={{
display: 'grid',
placeItems: 'center',
height: 'calc(100vh - 64px)',
textAlign: 'center',
}}
maxWidth="xs"
>
<Box sx={{ width: '100%' }}>
<Typography variant="h5" component="h1">
Delete Account
</Typography>
<Typography variant="subtitle2" component="h2">
Confirm delete your account from Solar Network
</Typography>
<Collapse in={!!error} sx={{ width: '100%' }}>
<Alert sx={{ mt: 4 }} icon={<ErrorIcon fontSize="inherit" />} severity="error">
{error}
</Alert>
</Collapse>
<Box sx={{ mt: 3 }}>
<Typography variant="body2" gutterBottom>
Are you sure you want to delete your account? This action is irreversible. All the resources created by you
or related to your account will be deleted.
</Typography>
<Typography variant="body2">
If you have changed your mind, you can close this tab at any time, nothing will be affected.
</Typography>
<Button sx={{ mt: 3 }} onClick={() => confirm()} disabled={busy}>
Confirm
</Button>
</Box>
</Box>
</Container>
)
}

View File

@ -0,0 +1,77 @@
import { sni } from '@/services/network'
import { Container, Box, Typography, Alert, Collapse, Button, TextField } from '@mui/material'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import ErrorIcon from '@mui/icons-material/Error'
export type SnResetPasswordForm = {
password: string
}
export default function AccountPasswordReset() {
const router = useRouter()
const { handleSubmit, register } = useForm<SnResetPasswordForm>()
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
async function confirm(data: any) {
try {
setBusy(true)
await sni.patch('/cgi/id/users/me/password-reset', {
code: router.query['code'] as string,
new_password: data.password,
})
router.push('/')
} catch (err: any) {
setError(err.toString())
} finally {
setBusy(false)
}
}
return (
<Container
sx={{
display: 'grid',
placeItems: 'center',
height: 'calc(100vh - 64px)',
textAlign: 'center',
}}
maxWidth="xs"
>
<Box sx={{ width: '100%' }}>
<Typography variant="h5" component="h1">
Reset Password
</Typography>
<Typography variant="subtitle2" component="h2">
Reset your password on Solar Network
</Typography>
<Collapse in={!!error} sx={{ width: '100%' }}>
<Alert sx={{ mt: 4 }} icon={<ErrorIcon fontSize="inherit" />} severity="error">
{error}
</Alert>
</Collapse>
<form onSubmit={handleSubmit(confirm)}>
<Box sx={{ mt: 3, display: 'flex', flexDirection: 'column', width: '100%', gap: 2, textAlign: 'center' }}>
<TextField
label="New Password"
type="password"
autoComplete="new-password"
{...register('password', { required: true })}
/>
<Button type="submit" disabled={busy}>
Next
</Button>
</Box>
</form>
</Box>
</Container>
)
}

View File

@ -1,110 +0,0 @@
---
import { Image } from 'astro:assets'
import { Icon } from 'astro-icon/components'
import Layout from '@/layouts/Layout.astro'
import CompanyAsideImage from '@/assets/images/company-aside.webp'
import AppStoreGetImage from '@/assets/images/app-store/get-it-on-black.svg'
import ProductSnPreviewImage from '@/assets/images/products/solar-network.webp'
---
<Layout>
<div class="section container mx-auto grid-cols-1 lg:grid-cols-3" id="intro">
<div
class="sub-section flex flex-col items-end justify-center lg:col-span-1"
>
<h1 class="text-4xl font-bold text-right">Solsynth LLC</h1>
<p class="text-lg text-right">
A vibrant creating wonderful software and hope the future will be
brighter.
</p>
<a class="link flex items-center gap-1 mt-2" href="#work-featured">
<span>See some of our works</span>
<Icon name="material-symbols:arrow-downward" />
</a>
</div>
<div class="sub-section flex items-center lg:col-span-2">
<Image
src={CompanyAsideImage}
alt="galaxy"
class="rounded-lg shadow-xl"
/>
</div>
</div>
<div class="section-flex py-12 shadow-lg bg-base-300" id="work-featured">
<div class="section container mx-auto grid-cols-1 lg:grid-cols-3">
<div
class="sub-section flex flex-col items-start lg:items-end justify-center lg:col-span-1"
>
<div class="badge badge-primary flex gap-1 items-center mb-2">
<Icon name="material-symbols:star" />
<span>Featured Project</span>
</div>
<h2 class="text-3xl font-bold lg:text-right">Solar Network</h2>
<p class="text-lg lg:text-right">
The next generation social network. But not only for social media.
</p>
<blockquote
class="text-md lg:text-right bg-neutral text-neutral-content px-7 py-5 rounded-xl font-mono mt-2"
>
Social Network, Redefined.
</blockquote>
<div class="flex gap-3 mt-4 items-center text-right">
<span
class="opacity-75 text-xs max-lg:order-last"
style="line-height: 1"
>
Also supports Android, Windows, and any modern web browser
</span>
<a
href="https://apps.apple.com/us/app/solian/id6499032345?itscg=30200&itsct=apps_box_link&mttnsubad=6499032345"
>
<Image src={AppStoreGetImage} alt="get it on app store" />
</a>
</div>
<a
class="link flex items-center gap-1 mt-3"
href="/products/solar-network"
>
<span>Learn more about Solar Network</span>
<Icon name="material-symbols:arrow-right-alt" />
</a>
</div>
<div class="sub-section flex items-center lg:col-span-2">
<Image
src={ProductSnPreviewImage}
alt="solar network cross-platform preview"
class="rounded-lg shadow-xl"
/>
</div>
</div>
</div>
</Layout>
<style>
html,
body {
scroll-behavior: smooth;
}
.section {
min-height: 90vh;
width: 100%;
gap: 0 2rem;
padding: 0 1rem;
display: grid;
}
.section-flex {
width: 100%;
}
.sub-section {
padding: 1rem;
}
</style>

116
src/pages/index.tsx Normal file
View File

@ -0,0 +1,116 @@
import { Box, Chip, Container, Grid2 as Grid, Link, Paper, Typography } from '@mui/material'
import { Roboto_Serif } from 'next/font/google'
import NextLink from 'next/link'
import Image from 'next/image'
import StarIcon from '@mui/icons-material/Star'
import ArrowForward from '@mui/icons-material/ArrowForward'
import ArrowDownward from '@mui/icons-material/ArrowDownward'
import LaunchIcon from '@mui/icons-material/Launch'
import ImgSolarNetworkLabeled from '@/assets/products/solar-network/labeled.webp'
const fontSerif = Roboto_Serif({
subsets: ['latin'],
weight: ['300'],
display: 'swap',
style: 'italic',
})
export default function Home() {
return (
<>
<Container sx={{ py: 24, display: 'flex', flexDirection: 'column', gap: 32 }}>
<Box>
<Image src="/logo.png" width={128} height={128} alt="company logo" className="mb-2" />
<Typography variant="h3" component="h1" gutterBottom>
Welcome to <br />
the Solsynth Capital.
</Typography>
<Typography variant="body1" fontSize={20} sx={{ mb: 2 }}>
A vibrant creating wonderful software and hope the future will be brighter.
</Typography>
<Link href="#products">
Explore our works
<ArrowDownward sx={{ fontSize: 15, marginLeft: 0.5 }} />
</Link>
</Box>
<Box id="products">
<Grid container columns={{ xs: 1, sm: 1, md: 2 }} spacing={4}>
<Grid size={1}>
<Chip
label="Featured Project"
variant="outlined"
icon={<StarIcon sx={{ fontSize: 18 }} />}
sx={{ px: 1, mb: 2 }}
/>
<Typography variant="h4" component="h2">
Solar Network
</Typography>
<Typography variant="body1" fontSize={16} sx={{ mb: 2 }}>
The next generation social network. But not only a social media.
</Typography>
<Typography
variant="subtitle1"
fontSize={26}
fontFamily={fontSerif.style.fontFamily}
sx={{ mb: 2, width: 'fit-content', fontStyle: 'italic' }}
className="textmarker-effect active"
>
Social Network, Redefined.
</Typography>
<NextLink passHref href="/products/solar-network">
<Link component="span">
Explore more you can do with Solar Network
<ArrowForward sx={{ fontSize: 15, marginLeft: 0.5 }} />
</Link>
</NextLink>
</Grid>
<Grid size={1}>
<Paper sx={{ position: 'relative', aspectRatio: 16 / 10, width: '100%' }}>
<Image
src={ImgSolarNetworkLabeled}
alt="solian the app preview"
fill
style={{ objectFit: 'cover', borderRadius: '8px' }}
/>
</Paper>
</Grid>
</Grid>
</Box>
<Box id="production">
<Grid container columns={{ xs: 1, sm: 1, md: 2 }} spacing={4}>
<Grid size={1}>
<Image
src="/product-branding.webp"
alt="product branding mark"
width={256}
height={80}
style={{ marginLeft: '-20px' }}
/>
<Typography variant="h4" component="h2" sx={{ my: 2 }}>
Made by Solsynth
</Typography>
<Typography variant="body1" sx={{ mb: 2 }}>
There are a lot of other great projects was made by Solsynth, and most of them are open sourced. You can
check them on our GitHub organization.
</Typography>
<NextLink passHref href="https://www.github.com/Solsynth" target="_blank">
<Link component="span">
Check out our GitHub page
<LaunchIcon sx={{ fontSize: 15, marginLeft: 0.5 }} />
</Link>
</NextLink>
</Grid>
</Grid>
</Box>
</Container>
</>
)
}

228
src/pages/posts/[...id].tsx Normal file
View File

@ -0,0 +1,228 @@
import { getAttachmentUrl, sni } from '@/services/network'
import { SnPost } from '@/services/post'
import { listAttachment, SnAttachment } from '@/services/attachment'
import {
Grid2 as Grid,
Alert,
AlertTitle,
Avatar,
Box,
Collapse,
Container,
IconButton,
Link,
Typography,
Divider,
} from '@mui/material'
import { AttachmentItem } from '@/components/attachments/AttachmentItem'
import { GetServerSideProps, InferGetServerSidePropsType } from 'next'
import { useEffect, useMemo, useState } from 'react'
import { unified } from 'unified'
import Head from 'next/head'
import Image from 'next/image'
import rehypeSanitize from 'rehype-sanitize'
import rehypeStringify from 'rehype-stringify'
import remarkBreaks from 'remark-breaks'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import CloseIcon from '@mui/icons-material/Close'
export const getServerSideProps = (async (context) => {
const id = context.params!.id as string[]
try {
const { data: post } = await sni.get<SnPost>('/cgi/co/posts/' + id.join(':'))
if (post.body.content) {
let processor: any = unified().use(remarkParse)
if (post.type != 'article') {
processor = processor.use(remarkBreaks)
}
const out = await processor.use(remarkRehype).use(rehypeSanitize).use(rehypeStringify).process(post.body.content)
post.body.rawContent = post.body.content
post.body.content = String(out)
}
let attachments: SnAttachment[] = []
if (post.body.attachments) {
attachments = await listAttachment(post.body.attachments)
}
return { props: { post, attachments } }
} catch (err) {
console.error(err)
return {
notFound: true,
}
}
}) satisfies GetServerSideProps<{ post: SnPost; attachments: SnAttachment[] }>
export default function Post({ post, attachments }: InferGetServerSidePropsType<typeof getServerSideProps>) {
const appLink = useMemo(() => `https://sn.solsynth.dev/posts/${post.id}`, [post])
const link = useMemo(
() =>
post.alias && post.aliasPrefix
? `https://solsynth.dev/posts/${post.aliasPrefix}/${post.alias}`
: `https://solsynth.dev/posts/${post.id}`,
[post],
)
const title = useMemo(
() =>
post.body.title
? `${post.body.title} / @${post.publisher.name} / Solar Network`
: `Post #${post.id} / @${post.publisher.name} / Solar Network`,
[post],
)
const description = useMemo(
() =>
post.body.description ? post.body.description : post.body.rawContent.replaceAll('\n', ' ').substring(0, 200),
[post],
)
const image = useMemo(() => {
if (post.body.thumbnail) {
return getAttachmentUrl(post.body.thumbnail)
}
if (attachments) {
const images = attachments.filter((a) => a.mimetype.startsWith('image'))
if (images && images[0]) return getAttachmentUrl(images[0].rid)
}
return null
}, [post])
const video = useMemo(() => {
if (attachments) {
const videos = attachments.filter((a) => a.mimetype.startsWith('video'))
if (videos && videos[0]) return getAttachmentUrl(videos[0].rid)
}
return null
}, [post])
const audio = useMemo(() => {
if (attachments) {
const audios = attachments.filter((a) => a.mimetype.startsWith('audio'))
if (audios && audios[0]) return getAttachmentUrl(audios[0].rid)
}
return null
}, [post])
const [openAppHint, setOpenAppHint] = useState<boolean>()
useEffect(() => {
if (!localStorage.getItem('sol-hide-app-hint')) {
setOpenAppHint(true)
}
}, [])
useEffect(() => {
if (openAppHint === false) {
localStorage.setItem('sol-hide-app-hint', 'yes')
}
}, [openAppHint])
return (
<>
<Head>
<title>{title}</title>
<meta name="description" content={description} />
<meta name="author" content={`@${post.publisher.name}`} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:creator" content={`@${post.publisher.name}`} />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta property="og:url" content={link} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Solar Network" />
{image && <meta property="og:image" content={image} />}
{video && <meta property="og:video" content={video} />}
{audio && <meta property="og:audio" content={audio} />}
</Head>
<Collapse in={openAppHint}>
<Alert
variant="filled"
severity="info"
sx={{ borderRadius: 0, px: 3 }}
action={
<IconButton
aria-label="close"
color="inherit"
size="small"
onClick={() => {
setOpenAppHint(false)
}}
>
<CloseIcon fontSize="inherit" />
</IconButton>
}
>
<AlertTitle gutterBottom={false}>Open in Solian</AlertTitle>
All feature supported, cross-platform, the official app of Solar Network.{' '}
<Link href={appLink} color="#ffffff">
Launch
</Link>
</Alert>
</Collapse>
{post.body.thumbnail && (
<Box sx={{ aspectRatio: 16 / 9, position: 'relative', borderBottom: 1, borderTop: 1, borderColor: 'divider' }}>
<Image src={getAttachmentUrl(post.body.thumbnail)} alt="post thumbnail" fill />
</Box>
)}
<Container sx={{ mt: 3, pb: 5 }} maxWidth="md" component="article">
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', gap: 2 }}>
<Avatar src={getAttachmentUrl(post.publisher.avatar)} />
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography fontWeight="bold">{post.publisher.nick}</Typography>
<Typography fontFamily="monospace" fontSize={13} lineHeight={1.2}>
@{post.publisher.name}
</Typography>
</Box>
</Box>
</Box>
<Box sx={{ my: 2.5 }} display="flex" flexDirection="column" gap={1}>
{(post.body.title || post.body.content) && (
<Box>
{post.body.title && <Typography variant="h6">{post.body.title}</Typography>}
{post.body.description && <Typography variant="subtitle1">{post.body.description}</Typography>}
</Box>
)}
<Box display="flex" gap={2} sx={{ opacity: 0.8 }}>
<Typography variant="body2">
Published at {new Date(post.publishedAt ?? post.createdAt).toLocaleString()}
</Typography>
</Box>
</Box>
<Divider />
<Box sx={{ mt: 2.5, maxWidth: 'unset' }} className="prose prose-lg">
{post.body.content && <div dangerouslySetInnerHTML={{ __html: post.body.content }} />}
</Box>
{attachments && (
<Grid
container
spacing={2}
sx={{ mt: 3 }}
columns={{
xs: 1,
sm: Math.min(2, attachments.length),
md: Math.min(3, attachments.length),
lg: Math.min(4, attachments.length),
}}
>
{attachments.map((a) => (
<Grid size={1} key={a.id}>
<AttachmentItem item={a} />
</Grid>
))}
</Grid>
)}
</Container>
</>
)
}

View File

@ -1,167 +0,0 @@
---
export const prerender = false
import sanitizeHtml from 'sanitize-html'
import { Icon } from 'astro-icon/components'
import { SEO } from 'astro-seo'
import { marked } from 'marked'
import Layout from '@/layouts/Layout.astro'
import AttachmentRenderer from '@/components/AttachmentRenderer.astro'
import { getAttachmentUrl, fetchAttachmentMeta } from '@/scripts/attachment'
const { slug } = Astro.params
const baseUrl = import.meta.env.PUBLIC_SOLAR_NETWORK_URL
const resp = await fetch(`${baseUrl}/cgi/co/posts/${slug?.replace('/', ':')}`)
if (resp.status !== 200) {
return new Response(null, { status: 404 })
}
const data = await resp.json()
const rawContent = await marked(data.body.content as string, {
breaks: data.type == 'story',
})
const content = sanitizeHtml(rawContent)
const attachments = await fetchAttachmentMeta(data.body.attachments)
const title = data.body?.title ? data.body.title : `Post #${data.id}`
const description =
data.body?.description ?? data.body?.content?.substring(0, 200) + '...'
const url =
data.alias && data.alias_prefix
? `https://solsynth.dev/posts/${data.alias_prefix}/${data.alias}`
: `https://solsynth.dev/posts/${data.id}`
---
<head>
<SEO
title={`${title} | Solar Network`}
description={description}
canonical={url}
charset='UTF-8'
openGraph={{
optional: {
siteName: 'Solar Network',
description,
video: getAttachmentUrl(
attachments.find((a) => a.mimetype.startsWith('video'))?.rid
),
audio: getAttachmentUrl(
attachments.find((a) => a.mimetype.startsWith('audio'))?.rid
),
},
basic: {
title,
type: 'article',
image: data.body?.thumbnail
? getAttachmentUrl(data.body.thumbnail)
: getAttachmentUrl(
attachments.find((a) => a.mimetype.startsWith('image'))?.rid
),
url,
},
article: {
publishedTime: new Date(data.created_at).toISOString(),
modifiedTime: new Date(data.updated_at).toISOString(),
authors: ['@' + data.publisher.name],
},
}}
twitter={{
card: 'summary_large_image',
title,
description,
creator: '@' + data.publisher.name,
image: data.body?.thumbnail
? getAttachmentUrl(data.body.thumbnail)
: getAttachmentUrl(
attachments.find((a) => a.mimetype.startsWith('image'))?.rid
),
}}
/>
</head>
<Layout title={title} trailingTitle="Solar Network">
<div role="alert" class="alert shadow-lg px-12 m-0 rounded-none mb-5">
<Icon
name="material-symbols:ungroup"
class="stroke-info fill-info h-6 w-6 shrink-0"
/>
<div>
<h3 class="font-bold">Open in the Solian</h3>
<div class="text-xs">
The most modern, user-friendly, and official Solar Network app.
</div>
</div>
<div class="flex gap-2">
<a class="btn btn-sm" href="/products/solar-network#downloads">Get</a>
<a class="btn btn-sm" href={`https://sn.solsynth.dev/posts/${data.id}`}
>Open</a
>
</div>
</div>
<div class="container lg:max-w-[75ch] px-8 mx-auto">
<div class="flex gap-4 items-center mb-5">
<div class="avatar">
<div class="w-12 rounded-full">
<img src={getAttachmentUrl(data.publisher.avatar)} alt="avatar" />
</div>
</div>
<div class="userinfo flex flex-col">
<span class="flex gap-2 items-baseline">
<span class="text-md font-bold">{data.publisher.nick}</span>
<span class="text-xs font-mono">@{data.publisher.name}</span>
</span>
<span class="text-sm line-clamp-2 overflow-ellipsis"
>{data.publisher.description}</span
>
</div>
</div>
{
data.repost_id && (
<div role="alert" class="alert mb-5 py-2 mx-[-4px]">
<Icon
name="material-symbols:format-quote"
class="stroke-info fill-info h-6 w-6 shrink-0"
/>
<span>
This post is reposting post{' '}
<span class="font-mono">#{data.repost_id}</span>
</span>
<div>
<a class="btn btn-sm" href={`/posts/${data.repost_id}`}>
See reposted post
</a>
</div>
</div>
)
}
<article>
<div class="prose max-w-none max-md:prose-lg min-w-0" set:html={content} />
{
attachments && (
<div
class="attachment-list mt-5 gap-4 grid grid-cols-1"
class:list={
attachments.length >= 2 ? 'md:grid-cols-2' : 'md:grid-cols-1'
}
>
{attachments.map((attachment) => (
<div class="attachment">
<AttachmentRenderer data={attachment} />
</div>
))}
</div>
)
}
</article>
</div>
</Layout>

View File

@ -1,149 +0,0 @@
---
export const prerender = false
import sanitizeHtml from 'sanitize-html'
import { Icon } from 'astro-icon/components'
import { marked } from 'marked'
import Layout from '@/layouts/Layout.astro'
import AttachmentRenderer from '@/components/AttachmentRenderer.astro'
import { getAttachmentUrl, fetchAttachmentMeta } from '@/scripts/attachment'
const page = parseInt(Astro.url.searchParams.get('page') ?? '1') || 1
async function getPosts() {
const baseUrl = import.meta.env.PUBLIC_SOLAR_NETWORK_URL
const res = await fetch(
`${baseUrl}/cgi/co/posts?take=10&offset=${Math.max(page - 1, 0) * 10}`
)
const data = await res.json()
const posts = await Promise.all(
data['data'].map(async (ele: any) => {
if (ele.body?.content) {
ele.body.content = await parseContent(
ele.body.content,
ele.type == 'story'
)
}
if (ele.body?.attachments) {
ele.body.attachments = await fetchAttachmentMeta(ele.body.attachments)
}
return ele
})
)
return posts
}
const posts = await getPosts()
async function parseContent(data: string, useBreaks: boolean = false) {
const rawContent = await marked(data, {
breaks: useBreaks,
})
return sanitizeHtml(rawContent)
}
---
<Layout title="Posts" trailingTitle='Solar Network'>
<div role="alert" class="alert shadow-lg px-12 m-0 rounded-none mb-5">
<Icon
name="material-symbols:ungroup"
class="stroke-info fill-info h-6 w-6 shrink-0"
/>
<div>
<h3 class="font-bold">Open in the Solian</h3>
<div class="text-xs">
The most modern, user-friendly, and official Solar Network app.
</div>
</div>
<div class="flex gap-2">
<a class="btn btn-sm" href="/products/solar-network#downloads">Get</a>
<a class="btn btn-sm" href="https://sn.solsynth.dev/posts">Open</a>
</div>
</div>
<div
class="container max-w-[85ch] mx-auto mt-[15vh] px-12 flex flex-col gap-4"
>
<h1 class="text-2xl font-bold">Posts</h1>
<p>Explore the posts all over the Solar Network.</p>
<div class="flex flex-col gap-4 mt-4 mx-[-16px]">
{
posts.map((ele: any) => (
<a href={`/posts/${ele.id}`}>
<div class="card bg-base-100 w-full border-neutral border">
<div class="card-body">
<div class="flex gap-4 items-center mb-5">
<div class="avatar">
<div class="w-12 rounded-full">
<img
src={getAttachmentUrl(ele.publisher.avatar)}
alt="avatar"
/>
</div>
</div>
<div class="userinfo flex flex-col">
<span class="flex gap-2 items-baseline">
<span class="text-md font-bold">
{ele.publisher.nick}
</span>
<span class="text-xs font-mono">
@{ele.publisher.name}
</span>
</span>
<span class="text-sm overflow-ellipsis">
{new Date(ele.created_at).toLocaleString()}
</span>
</div>
</div>
{ele.body.title && (
<h2 class="card-title">
{ele.body?.title ?? `Post #${ele.id}`}
</h2>
)}
<article>
<div
class="prose max-w-none max-md:prose-lg max-w-0"
set:html={ele.body?.content ?? ''}
/>
{ele.body?.attachments && (
<div
class="attachment-list mt-5 gap-4 grid grid-cols-1"
class:list={
ele.body.attachments.length >= 2
? 'md:grid-cols-2'
: 'md:grid-cols-1'
}
>
{ele.body.attachments.map((attachment: any) => (
<div class="attachment">
<AttachmentRenderer data={attachment} />
</div>
))}
</div>
)}
</article>
</div>
</div>
</a>
))
}
<div class="flex justify-center items-center mt-12">
<div class="join">
<a
class="join-item btn"
class:list={page == 1 ? 'btn-disabled' : ''}
href={`/posts?page=${page - 1}`}>«</a
>
<button class="join-item btn">Page {page}</button>
<a class="join-item btn" href={`/posts?page=${page + 1}`}>»</a>
</div>
</div>
</div>
</div>
</Layout>

135
src/pages/posts/index.tsx Normal file
View File

@ -0,0 +1,135 @@
import { AttachmentItem } from '@/components/attachments/AttachmentItem'
import { SnAttachment, listAttachment } from '@/services/attachment'
import { getAttachmentUrl, sni } from '@/services/network'
import { SnPost } from '@/services/post'
import { Avatar, Box, Container, Divider, Grid2 as Grid, Pagination, Paper, Typography } from '@mui/material'
import { GetServerSideProps, InferGetServerSidePropsType } from 'next'
import Link from 'next/link'
import { useRouter } from 'next/router'
import rehypeSanitize from 'rehype-sanitize'
import rehypeStringify from 'rehype-stringify'
import remarkBreaks from 'remark-breaks'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import { unified } from 'unified'
type SnPostWithAttachments = SnPost & { attachments: SnAttachment[] }
export const getServerSideProps = (async (context) => {
let page: number = parseInt(context.query.page as string)
if (isNaN(page)) page = 1
const countPerPage = 10
try {
const { data: resp } = await sni.get<{ data: SnPost[]; count: number }>('/cgi/co/posts', {
params: {
take: countPerPage,
offset: (page - 1) * countPerPage,
},
})
let posts: SnPostWithAttachments[] = resp.data as SnPostWithAttachments[]
for (let idx = 0; idx < posts.length; idx++) {
let post = posts[idx]
if (post.body.content) {
let processor: any = unified().use(remarkParse)
if (post.type != 'article') {
processor = processor.use(remarkBreaks)
}
const out = await processor
.use(remarkRehype)
.use(rehypeSanitize)
.use(rehypeStringify)
.process(post.body.content)
post.body.rawContent = post.body.content
post.body.content = String(out)
}
if (post.body.attachments) {
post.attachments = await listAttachment(post.body.attachments)
}
posts[idx] = post
}
return { props: { posts, page, pages: Math.ceil(resp.count / countPerPage) } }
} catch (err) {
console.error(err)
return {
notFound: true,
}
}
}) satisfies GetServerSideProps<{ posts: SnPostWithAttachments[]; page: number; pages: number }>
export default function PostList({ posts, page, pages }: InferGetServerSidePropsType<typeof getServerSideProps>) {
const router = useRouter()
return (
<Container sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 2 }} maxWidth="md">
{posts.map((p) => (
<Paper key={p.id} sx={{ px: 2, py: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', gap: 2 }}>
<Avatar src={getAttachmentUrl(p.publisher.avatar)} />
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography fontWeight="bold">{p.publisher.nick}</Typography>
<Typography fontFamily="monospace" fontSize={13} lineHeight={1.2}>
@{p.publisher.name}
</Typography>
</Box>
</Box>
</Box>
<Link href={`/posts/${p.id}`} passHref>
<Box>
<Box sx={{ mt: 1.5, mb: 1 }} display="flex" flexDirection="column" gap={0.5}>
{(p.body.title || p.body.content) && (
<Box>
{p.body.title && <Typography variant="h6">{p.body.title}</Typography>}
{p.body.description && <Typography variant="subtitle1">{p.body.description}</Typography>}
</Box>
)}
<Box display="flex" gap={2} sx={{ opacity: 0.8 }}>
<Typography variant="body2">
Published at {new Date(p.publishedAt ?? p.createdAt).toLocaleString()}
</Typography>
</Box>
</Box>
<Divider />
<Box sx={{ maxWidth: 'unset' }} className="prose prose-md">
{p.body.content && <div dangerouslySetInnerHTML={{ __html: p.body.content }} />}
</Box>
</Box>
</Link>
{p.attachments && (
<Grid
container
spacing={2}
columns={{
xs: 1,
sm: Math.min(2, p.attachments.length),
md: Math.min(3, p.attachments.length),
lg: Math.min(4, p.attachments.length),
}}
>
{p.attachments.map((a) => (
<Grid size={1} key={a.id}>
<AttachmentItem item={a} />
</Grid>
))}
</Grid>
)}
</Paper>
))}
<Pagination
count={pages}
page={page}
sx={{ mx: 'auto', mb: 5, mt: 3 }}
onChange={(_, page) => router.push('/posts?page=' + page)}
/>
</Container>
)
}

View File

@ -1,176 +0,0 @@
---
import { Image } from 'astro:assets'
import { Icon } from 'astro-icon/components'
import Layout from '@/layouts/Layout.astro'
import ProductSnPreviewImage from '../../assets/images/products/solar-network-alpha.webp'
---
<Layout title="Solar Network">
<div class="container mx-auto mt-[35vh]" id="intro">
<div class="text-center">
<h1 class="text-4xl font-bold">Solar Network</h1>
<p>The next generation Social Network platform.</p>
<div class="flex justify-center gap-2 mt-5">
<a class="btn btn-primary" href="#downloads">
Download <Icon name="material-symbols:download" size={20} />
</a>
</div>
</div>
<Image
src={ProductSnPreviewImage}
alt="solar network cross-platform preview"
class="mt-5"
/>
<div id="downloads" class="mt-24 text-center">
<div class="flex flex-col justify-center">
<h2 class="text-2xl font-bold">Downloads</h2>
</div>
<div class="flex flex-wrap mx-8 items-center justify-center gap-4 my-5">
<a
href="https://apps.apple.com/us/app/solian/id6499032345?itscg=30200&itsct=apps_box_link&mttnsubad=6499032345"
class="btn btn-secondary max-md:btn-wide"
>
<Icon name="simple-icons:appstore" />
iOS / macOS (App Store)
</a>
<a
href="https://testflight.apple.com/join/YJ0lmN6O"
class="btn btn-secondary btn-outline max-md:btn-wide"
>
<Icon name="simple-icons:apple" />
iOS / macOS (TestFlight)
</a>
<a
href="https://files.solsynth.dev/production01/solian/app-arm64-v8a-release.apk"
class="btn btn-secondary max-md:btn-wide"
>
<Icon name="simple-icons:android" />
Android (apk file)
</a>
<a
href="https://files.solsynth.dev/production01/solian/windows-x86_64-release.zip"
class="btn btn-secondary max-md:btn-wide"
>
<Icon name="simple-icons:windows" />
Windows (executable)
</a>
<a
href="https://sn.solsynth.dev"
class="btn btn-secondary btn-outline max-md:btn-wide"
>
<Icon name="material-symbols:globe" />
Web (browser)
</a>
</div>
<div class="mx-8">
<p class="opacity-80 mb-2">
You will download Solian, which is the official app made for Solar
Network.
</p>
<p class="opacity-75 text-sm">
Explore more platform distribution files on the{' '}
<a class="link" href="https://files.solsynth.dev/production01/solian"
>Solarfiles</a
>
</p>
<p class="opacity-75 text-sm">
Check out the Solian source code on{' '}
<a class="link" href="https://git.solsynth.dev/HyperNet/Surface"
>Solargit</a
>
</p>
</div>
</div>
<div id="faq" class="mt-24">
<div class="flex text-center justify-center">
<h2 class="text-2xl font-bold">Frequently Asked Questions</h2>
</div>
<div
class="max-w-[85ch] mx-auto bg-neutral text-neutral-content shadow-lg rounded-lg p-2 mt-5"
>
<div class="join join-vertical w-full">
<div class="collapse collapse-arrow join-item">
<input type="radio" name="my-accordion-4" checked="checked" />
<div class="collapse-title text-xl font-medium">
What's the relationship between Solar Network and Solian?
</div>
<div class="collapse-content">
<p>
Solian is the official app made for Solar Network. And the Solar
Network is the official HyperNet instance hosted by Solsynth
LLC.
</p>
<p>
For simple, Solian is the app, and the Solar Network is the
platform.
</p>
</div>
</div>
<div class="collapse collapse-arrow join-item">
<input type="radio" name="my-accordion-4" checked="checked" />
<div class="collapse-title text-xl font-medium">
What's the relationship between Solar Network and HyperNet?
</div>
<div class="collapse-content">
<p>
HyperNet is the entire project including frontend app (also
knowns as Solian for public) and the backend server. And the
Solar Network is the official HyperNet instance which hosted and
managed by Solsynth LLC who developed the HyperNet Project.
</p>
</div>
</div>
<div class="collapse collapse-arrow join-item">
<input type="radio" name="my-accordion-4" />
<div class="collapse-title text-xl font-medium">
Which rules do I need to follow while using Solar Network?
</div>
<div class="collapse-content">
<p>
Check out our{' '}
<a href="/terms/user-agreements" class="link">
User Agreements
</a>
for a detailed explanation of what you can do and cannot do on Solar
Network. If you violate any of these rules, we have the right to
suspend or terminate your account.
</p>
</div>
</div>
<div class="collapse collapse-arrow join-item">
<input type="radio" name="my-accordion-4" />
<div class="collapse-title text-xl font-medium">
If I have any question about Solar Network, where can I get help?
</div>
<div class="collapse-content">
<p>
Feel free to email us at{' '}
<a href="mailto:lily@solsynth.dev" class="link">
<address>lily@solsynth.dev</address>
</a>
Our customer service team will try our best to help you solve your
issue.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</Layout>
<style>
html,
body {
scroll-behavior: smooth;
}
</style>

View File

@ -0,0 +1,230 @@
import {
Link,
Container,
Box,
Typography,
Chip,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Accordion,
AccordionDetails,
AccordionSummary,
} from '@mui/material'
import { JSX } from 'react'
import { Roboto_Serif } from 'next/font/google'
import Image from 'next/image'
import NextLink from 'next/link'
import ArrowDownward from '@mui/icons-material/ArrowDownward'
import DownloadIcon from '@mui/icons-material/Download'
import LaunchIcon from '@mui/icons-material/Launch'
import AppleIcon from '@mui/icons-material/Apple'
import AndroidIcon from '@mui/icons-material/Android'
import WindowIcon from '@mui/icons-material/Window'
import WebIcon from '@mui/icons-material/Public'
import CodeIcon from '@mui/icons-material/Code'
import ImgSolarNetworkIcon from '@/assets/products/solar-network/icon.png'
import ImgSolarNetworkAlpha from '@/assets/products/solar-network/alpha.webp'
import 'animate.css'
interface DownloadableAsset {
icon: JSX.Element
title: string
href: string
open?: boolean
}
interface AskableQuestion {
question: string
answer: string
}
const fontSerif = Roboto_Serif({
subsets: ['latin'],
weight: ['300'],
display: 'swap',
style: 'italic',
})
export async function getStaticProps() {
return {
props: {
title: 'Solar Network',
},
}
}
export default function ProductSolarNetwork() {
const downloadableAssets: DownloadableAsset[] = [
{
icon: <AppleIcon />,
title: 'iOS / macOS (App Store)',
href: 'https://apps.apple.com/us/app/solian/id6499032345?itscg=30200&itsct=apps_box_link&mttnsubad=6499032345',
},
{
icon: <AppleIcon />,
title: 'iOS / macOS (TestFlight)',
href: 'https://testflight.apple.com/join/YJ0lmN6O',
},
{
icon: <AndroidIcon />,
title: 'Android',
href: 'https://files.solsynth.dev/production01/solian/app-arm64-v8a-release.apk',
},
{
icon: <WindowIcon />,
title: 'Windows',
href: 'https://files.solsynth.dev/production01/solian/windows-x86_64-release.zip',
},
{
icon: <WebIcon />,
title: 'Web',
href: 'https://sn.solsynth.dev',
open: true,
},
{
icon: <CodeIcon />,
title: 'Source Code',
href: 'https://github.com/Solsynth/HyperNet.Surface',
},
]
const askableQuestions: AskableQuestion[] = [
{
question: "What's the relationship between Solar Network and Solian?",
answer:
'Solian is the official app made for Solar Network. And the Solar Network is the official HyperNet instance hosted by Solsynth LLC. For simple, Solian is the app, and the Solar Network is the platform.',
},
{
question: "What's the relationship between Solar Network and HyperNet?",
answer:
'HyperNet is the entire project including frontend app (also knowns as Solian for public) and the backend server. And the Solar Network is the official HyperNet instance which hosted and managed by Solsynth LLC who developed the HyperNet Project.',
},
{
question: 'Which rules do I need to follow while using Solar Network?',
answer:
'Check out our Terms & Conditions for a detailed explanation of what you can do and cannot do on Solar Network. If you violate any of these rules, we have the right to suspend or terminate your account., you can see them in the drawer.',
},
{
question: 'If I have any question about Solar Network, where can I get help?',
answer: 'Feel free to email as at lily@solsynth.dev',
},
]
return (
<Container sx={{ py: 24, display: 'flex', flexDirection: 'column', gap: 32 }}>
<Box sx={{ textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Image
src={ImgSolarNetworkIcon}
width={128}
height={128}
style={{ objectFit: 'cover' }}
className="shadow-xl rounded-2xl mx-auto mb-8 border border-1 border-gray-200"
alt="solar network icon"
/>
<Box position="relative" width="fit-content" className="animate__animated animate__fadeInUp">
<Typography variant="h4" component="h1">
Solar Network
</Typography>
<Box
position="absolute"
top={-14}
right={-24}
sx={{ rotate: '30deg' }}
className="animate__animated animate__pulse animate__infinite"
>
<Chip
label="2.0"
variant="outlined"
sx={{ fontFamily: 'monospace', backgroundColor: 'background.default', fontSize: 12 }}
/>
</Box>
</Box>
<Typography variant="subtitle1" component="h1" className="animate__animated animate__fadeInUp">
The next generation Social Network platform.
</Typography>
<Typography
variant="subtitle1"
fontSize={26}
fontFamily={fontSerif.style.fontFamily}
sx={{ mt: 2.5, width: 'fit-content', fontStyle: 'italic' }}
className="textmarker-effect animate__animated animate__fadeInUp"
>
Social Network, Redefined.
</Typography>
<Link href="#download" sx={{ my: 2.5 }}>
Download <DownloadIcon sx={{ fontSize: 15, marginLeft: 0.5 }} />
</Link>
<Box position="relative" width="100%" sx={{ aspectRatio: 16 / 10, mt: 5 }}>
<Image src={ImgSolarNetworkAlpha} fill alt="solar network screenshot" style={{ objectFit: 'cover' }} />
</Box>
</Box>
<Box id="download">
<Typography variant="h5" component="h2" textAlign="center" sx={{ mb: 5 }}>
Download
</Typography>
<Table sx={{ maxWidth: '800px', marginX: 'auto' }} aria-label="download table">
<TableHead>
<TableRow>
<TableCell />
<TableCell>Platform</TableCell>
<TableCell align="right">Distribution</TableCell>
</TableRow>
</TableHead>
<TableBody>
{downloadableAssets.map((a) => (
<TableRow key={a.href}>
<TableCell>{a.icon}</TableCell>
<TableCell>{a.title}</TableCell>
<TableCell align="right">
<NextLink passHref href={a.href} target="_blank">
{a.open ? (
<Link component="span">
Open now
<LaunchIcon sx={{ fontSize: 15, marginLeft: 0.5 }} />
</Link>
) : (
<Link component="span">
Download now
<DownloadIcon sx={{ fontSize: 15, marginLeft: 0.5 }} />
</Link>
)}
</NextLink>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
<Box id="faq">
<Typography variant="h5" component="h2" textAlign="center" sx={{ mb: 5 }}>
Frequently Asked Questions
</Typography>
<Box sx={{ maxWidth: '800px', marginX: 'auto' }}>
{askableQuestions.map((q) => (
<Accordion key={q.question}>
<AccordionSummary expandIcon={<ArrowDownward />} aria-controls="panel1-content" id="panel1-header">
<Typography component="span">{q.question}</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography variant="body1">{q.answer}</Typography>
</AccordionDetails>
</Accordion>
))}
</Box>
</Box>
</Container>
)
}

View File

@ -1,52 +0,0 @@
---
import Layout from '@/layouts/Layout.astro'
---
<Layout title="Terms & Conditions">
<div
class="container max-w-[85ch] mx-auto mt-[25vh] px-12 flex flex-col gap-4"
>
<h1 class="text-2xl font-bold">Terms & Conditions</h1>
<p>
This place is the collections of all the terms and conditions that you
will have to agree to in order to use our products.
</p>
<p>
We're trying to make it as simple as possible. And it's good for both of
us. You do not need care about this in normal. Just makes our lawyers
happy. <i>Do we really have a lawyer?</i>
</p>
<p>
The information here may changed from time to time. Please refresh to
check this page for the latest version.
</p>
<div class="flex flex-col gap-4 mt-4 mx-[-16px]">
<a href="/terms/user-agreements">
<div class="card bg-base-100 w-full border-neutral border">
<div class="card-body">
<h2 class="card-title">User Agreements</h2>
<p>
The User Agreements for users who using the Solar Network and
other products from us.
</p>
</div>
</div>
</a>
<a href="/terms/privacy-policy">
<div class="card bg-base-100 w-full border-neutral border">
<div class="card-body">
<h2 class="card-title">Privacy Policy</h2>
<p>
The Privacy Policy shows we how to process and handle the data
provided by you.
</p>
</div>
</div>
</a>
</div>
</div>
</Layout>

52
src/pages/terms/index.tsx Normal file
View File

@ -0,0 +1,52 @@
import { Box, Button, Card, CardActions, CardContent, Container, Typography } from '@mui/material'
import Link from 'next/link'
export default function Terms() {
return (
<Container maxWidth="md">
<Typography variant="h4" component="h1" gutterBottom sx={{ mt: 2 }}>
Terms & Conditions
</Typography>
<Typography variant="body1">
Nothing too special here, just some legal files which make our lawyers happy.{' '}
<del>Do we really have a lawyer?</del>
</Typography>
<Box display="flex" flexDirection="column" gap={2} sx={{ mt: 2 }}>
<Card>
<CardContent>
<Typography variant="h5" component="h2" gutterBottom>
Privacy Policy
</Typography>
<Typography variant="body2">Learn about how do we protect your data and privacy.</Typography>
</CardContent>
<CardActions>
<Link href="/terms/privacy-policy" passHref>
<Button size="small">Read this policy</Button>
</Link>
</CardActions>
</Card>
<Card>
<CardContent>
<Typography variant="h5" component="h2" gutterBottom>
User Agreements
</Typography>
<Typography variant="body2">
Learn about how do we dealing the user generated content on Solar Network, distrubution of our products
and more.
</Typography>
</CardContent>
<CardActions>
<Link href="/terms/user-agreements" passHref>
<Button size="small">Read this agreements</Button>
</Link>
</CardActions>
</Card>
</Box>
</Container>
)
}

View File

@ -1,61 +0,0 @@
---
import Layout from '@/layouts/Layout.astro'
---
<Layout title="User Agreements">
<div
class="container max-w-[85ch] mx-auto mt-[25vh] px-12 flex flex-col gap-4"
>
<h1 class="text-4xl font-bold">Privacy Policy</h1>
<article class="prose prose-lg max-w-none mt-5">
<h2 id="introduction">Introduction</h2>
<p>
We take your privacy seriously. This privacy policy outlines the types
of personal information we collect, how we use it, and the measures we
take to protect your data.
</p>
<h2 id="information-collection">Information Collection</h2>
<p>
We collect personal information only when necessary to provide our
services. This may include your name, email address, and other relevant
details.
</p>
<h2 id="use-of-information">Use of Information</h2>
<p>We use your personal information to:</p>
<ul>
<li>Provide and improve our services</li>
<li>Communicate with you about updates or important information</li>
<li>Ensure compliance with legal obligations</li>
</ul>
<h2 id="data-sharing">Data Sharing</h2>
<p>
We do not sell, trade, or share your personal information with third
parties except as required by law.
</p>
<h2 id="data-security">Data Security</h2>
<p>
We implement robust security measures to protect your personal
information from unauthorized access, alteration, disclosure, or
destruction.
</p>
<h2 id="your-rights">Your Rights</h2>
<p>You have the right to:</p>
<ul>
<li>Access the personal information we hold about you</li>
<li>Request corrections to your personal information</li>
<li>Request the deletion of your personal information</li>
</ul>
<h2 id="contact-us">Contact Us</h2>
<p>
If you have any questions or concerns about this privacy policy or our
data practices, please contact us at lily@solsynth.dev.
</p>
<h2 id="changes-to-this-policy">Changes to This Policy</h2>
<p>
We may update this privacy policy from time to time. Any changes will be
posted on this page, and we will notify you of any significant changes.
</p>
</article>
</div>
</Layout>

View File

@ -0,0 +1,65 @@
import { Box, Container, Divider, Typography } from '@mui/material'
export async function getStaticProps() {
return {
props: {
title: 'Privacy Policy',
},
}
}
export default function PrivacyPolicy() {
return (
<Container maxWidth="md">
<Typography variant="h3" component="h1" sx={{ mt: 2, mb: 5 }}>
Privacy Policy
</Typography>
<Divider />
<Box component="article" sx={{ my: 5, maxWidth: 'unset' }} className="prose prose-lg">
<h2 id="introduction">Introduction</h2>
<p>
We take your privacy seriously. This privacy policy outlines the types of personal information we collect, how
we use it, and the measures we take to protect your data.
</p>
<h2 id="information-collection">Information Collection</h2>
<p>
We collect personal information only when necessary to provide our services. This may include your name, email
address, and other relevant details.
</p>
<h2 id="use-of-information">Use of Information</h2>
<p>We use your personal information to:</p>
<ul>
<li>Provide and improve our services</li>
<li>Communicate with you about updates or important information</li>
<li>Ensure compliance with legal obligations</li>
</ul>
<h2 id="data-sharing">Data Sharing</h2>
<p>We do not sell, trade, or share your personal information with third parties except as required by law.</p>
<h2 id="data-security">Data Security</h2>
<p>
We implement robust security measures to protect your personal information from unauthorized access,
alteration, disclosure, or destruction.
</p>
<h2 id="your-rights">Your Rights</h2>
<p>You have the right to:</p>
<ul>
<li>Access the personal information we hold about you</li>
<li>Request corrections to your personal information</li>
<li>Request the deletion of your personal information</li>
</ul>
<h2 id="contact-us">Contact Us</h2>
<p>
If you have any questions or concerns about this privacy policy or our data practices, please contact us at
lily@solsynth.dev.
</p>
<h2 id="changes-to-this-policy">Changes to This Policy</h2>
<p>
We may update this privacy policy from time to time. Any changes will be posted on this page, and we will
notify you of any significant changes.
</p>
</Box>
</Container>
)
}

View File

@ -1,157 +0,0 @@
---
import Layout from '@/layouts/Layout.astro'
---
<Layout title="User Agreements">
<div
class="container max-w-[85ch] mx-auto mt-[25vh] px-12 flex flex-col gap-4"
>
<h1 class="text-4xl font-bold">User Agreements</h1>
<article class="prose prose-lg max-w-none mt-5">
<p>
This Agreement applies to all Solsynth LLC products, including but not
limited to Solar Network, Solian, DietaryGuard, AceField.
</p>
<h2 id="provision-and-discontinuance-of-service">
Provision and Discontinuance of Service
</h2>
<p>
Solsynth LLC will provide equal service to all living things in the
world, including grasshoppers. We also reserve the right to stop service
to any user. We do not require prior notice for discontinuing services
to some users.
</p>
<h2 id="user-generated-content">User Generated Content</h2>
<p>
Any content posted on Solar Network (including but not limited to posts,
articles, attachments) grants Solsynth LLC the right to display it by
default. Unless otherwise stated by the user, all rights are reserved by
the original poster, and reprints should be authorized by the original
poster.
</p>
<h3 id="reproduction-recognition">Reproduction Recognition</h3>
<p>
Unless specifically stated by the poster, all content is subject to the
definition of reprint in this section.
</p>
<p>
Republishing means uploading the content of the original post to another
platform or to the Solar Network, either unchanged or with minor
modifications, provided that simultaneous reposting of the post,
embedded components, and links to the presentation do not constitute
republishing. Republishing also requires attribution when authorized by
the original poster.
</p>
<h3 id="freedom-of-speech">Freedom of Speech</h3>
<p>
We do not remove user-generated content except in cases of misuse of
resources. We will not ask any user to remove any content.
</p>
<p>
However, Solsynth LLC reserves the right to restrict and stop the
display of content to the public that violates community guidelines
(e.g., obscenity, violence, gore, anti-social, terrorist organizations,
etc.).
</p>
<p>
Although you have 100% freedom of speech on Solar Network. However,
please be aware that freedom of speech does not mean that you will not
be held accountable for what you say.
</p>
<h4 id="restriction-and-discontinuation">
Restriction and Discontinuation
</h4>
<ul>
<li>
<p>
Restriction of Display: Discontinuation of related tweets, while
retaining the right to access them directly through resource
identifiers and sharing links.
</p>
</li>
<li>
<p>
Cease display: stop all access to the resource by anyone other than
the author.
</p>
</li>
</ul>
<h2 id="resource-misuse-prevention-policy">
Resource Misuse Prevention Policy
</h2>
<p>
Although there are no capacity limitations for using Solar Network&#39;s
data hosting services, resources determined to be abusive will be
disenfranchised from some features. Solsynth LLC reserves the right to
reclaim space on previously uploaded resources for deletion.
</p>
<h3 id="determination-of-misuse">Determination of Misuse</h3>
<ul>
<li>
Uploading without using: e.g. uploading excessive attachments in Solar
Network&#39;s Interactive Attachment Pool and not linking them to
posts.
</li>
<li>
Meaningless Posts: meaningless shuffling or wasting of Solar
Network&#39;s storage resources
</li>
<li>
Misuse: using Solar Network&#39;s public resources as if they were
your own dedicated pool (see the Wiki&#39;s Dedicated Pools page for
details).
</li>
</ul>
<p>
The Solsynth Trust &amp; Safety Team is ultimately responsible for
determining misuse.
</p>
<h2 id="secondary-releases">Secondary Releases</h2>
<p>
A secondary release is when our assets are downloaded and re-hosted on
another site.
</p>
<h3 id="product-secondary-release">Product Secondary Release</h3>
<p>
Unless otherwise stated, Solsynth LLC products are not available for
secondary distribution, please do not download our product builds and
upload them twice to another site. Please do not download our product
builds and upload them to other sites. <strong
>Secondary distribution for commercial use is not permitted.
</strong>.
</p>
<p>
What you should do is post a link to our product on another site. Or use
the embedded component. And indicate Solsynth LLC All Rights Reserved.
</p>
<p>
If you want to build a mirror site of our products, please contact us to
waive this rule.
</p>
<h3 id="secondary-distribution-of-source-code">
Secondary distribution of source code
</h3>
<p>
We do not allow any form of redistribution of source code (except for
Forks). This includes, but is not limited to, mirroring code
repositories on GitHub or the Solsynth Code Repository to other Git
providers such as GitLab, Gitee, and so on.
<strong>Selling source code twice is not allowed. </strong>
</p>
<p>
For more information on source code usage regulations, please follow the
open source license used by the project.
</p>
<p>
If you would like to set up a mirror of our source code, please contact
us to waive this policy.
</p>
<hr />
<p>
Solsynth LLC reserves the right of final interpretation of this
agreement.
</p>
</article>
</div>
</Layout>

View File

@ -0,0 +1,121 @@
import { Box, Container, Divider, Typography } from '@mui/material'
export async function getStaticProps() {
return {
props: {
title: 'User Agreements',
},
}
}
export default function PrivacyPolicy() {
return (
<Container maxWidth="md">
<Typography variant="h3" component="h1" sx={{ mt: 2, mb: 5 }}>
User Agreements
</Typography>
<Divider />
<Box component="article" sx={{ my: 5, maxWidth: 'unset' }} className="prose prose-lg">
<p>
This Agreement applies to all Solsynth LLC products, including but not limited to Solar Network, Solian,
DietaryGuard, AceField.
</p>
<h2 id="provision-and-discontinuance-of-service">Provision and Discontinuance of Service</h2>
<p>
Solsynth LLC will provide equal service to all living things in the world, including grasshoppers. We also
reserve the right to stop service to any user. We do not require prior notice for discontinuing services to
some users.
</p>
<h2 id="user-generated-content">User Generated Content</h2>
<p>
Any content posted on Solar Network (including but not limited to posts, articles, attachments) grants
Solsynth LLC the right to display it by default. Unless otherwise stated by the user, all rights are reserved
by the original poster, and reprints should be authorized by the original poster.
</p>
<h3 id="reproduction-recognition">Reproduction Recognition</h3>
<p>
Unless specifically stated by the poster, all content is subject to the definition of reprint in this section.
</p>
<p>
Republishing means uploading the content of the original post to another platform or to the Solar Network,
either unchanged or with minor modifications, provided that simultaneous reposting of the post, embedded
components, and links to the presentation do not constitute republishing. Republishing also requires
attribution when authorized by the original poster.
</p>
<h3 id="freedom-of-speech">Freedom of Speech</h3>
<p>
We do not remove user-generated content except in cases of misuse of resources. We will not ask any user to
remove any content.
</p>
<p>
However, Solsynth LLC reserves the right to restrict and stop the display of content to the public that
violates community guidelines (e.g., obscenity, violence, gore, anti-social, terrorist organizations, etc.).
</p>
<p>
Although you have 100% freedom of speech on Solar Network. However, please be aware that freedom of speech
does not mean that you will not be held accountable for what you say.
</p>
<h4 id="restriction-and-discontinuation">Restriction and Discontinuation</h4>
<ul>
<li>
<p>
Restriction of Display: Discontinuation of related tweets, while retaining the right to access them
directly through resource identifiers and sharing links.
</p>
</li>
<li>
<p>Cease display: stop all access to the resource by anyone other than the author.</p>
</li>
</ul>
<h2 id="resource-misuse-prevention-policy">Resource Misuse Prevention Policy</h2>
<p>
Although there are no capacity limitations for using Solar Network&#39;s data hosting services, resources
determined to be abusive will be disenfranchised from some features. Solsynth LLC reserves the right to
reclaim space on previously uploaded resources for deletion.
</p>
<h3 id="determination-of-misuse">Determination of Misuse</h3>
<ul>
<li>
Uploading without using: e.g. uploading excessive attachments in Solar Network&#39;s Interactive Attachment
Pool and not linking them to posts.
</li>
<li>Meaningless Posts: meaningless shuffling or wasting of Solar Network&#39;s storage resources</li>
<li>
Misuse: using Solar Network&#39;s public resources as if they were your own dedicated pool (see the
Wiki&#39;s Dedicated Pools page for details).
</li>
</ul>
<p>The Solsynth Trust &amp; Safety Team is ultimately responsible for determining misuse.</p>
<h2 id="secondary-releases">Secondary Releases</h2>
<p>A secondary release is when our assets are downloaded and re-hosted on another site.</p>
<h3 id="product-secondary-release">Product Secondary Release</h3>
<p>
Unless otherwise stated, Solsynth LLC products are not available for secondary distribution, please do not
download our product builds and upload them twice to another site. Please do not download our product builds
and upload them to other sites. <strong>Secondary distribution for commercial use is not permitted.</strong>.
</p>
<p>
What you should do is post a link to our product on another site. Or use the embedded component. And indicate
Solsynth LLC All Rights Reserved.
</p>
<p>If you want to build a mirror site of our products, please contact us to waive this rule.</p>
<h3 id="secondary-distribution-of-source-code">Secondary distribution of source code</h3>
<p>
We do not allow any form of redistribution of source code (except for Forks). This includes, but is not
limited to, mirroring code repositories on GitHub or the Solsynth Code Repository to other Git providers such
as GitLab, Gitee, and so on.
<strong>Selling source code twice is not allowed. </strong>
</p>
<p>
For more information on source code usage regulations, please follow the open source license used by the
project.
</p>
<p>If you would like to set up a mirror of our source code, please contact us to waive this policy.</p>
<hr />
<p>Solsynth LLC reserves the right of final interpretation of this agreement.</p>
</Box>
</Container>
)
}

152
src/pages/users/[name].tsx Normal file
View File

@ -0,0 +1,152 @@
import { SnCheckInRecord } from '@/services/checkIn'
import { getAttachmentUrl, sni } from '@/services/network'
import { SnAccount, SnAccountBadgeMapping } from '@/services/user'
import { Avatar, Box, Card, CardContent, Container, Grid2 as Grid, Typography } from '@mui/material'
import { LineChart } from '@mui/x-charts'
import type { InferGetServerSidePropsType, GetServerSideProps } from 'next'
import Image from 'next/image'
export const getServerSideProps = (async (context) => {
const name = context.params!.name as string
try {
const { data: user } = await sni.get<SnAccount>('/cgi/id/users/' + name)
const { data: checkIn } = await sni.get<{ data: SnCheckInRecord[] }>('/cgi/id/users/' + name + '/check-in', {
params: { take: 14 },
})
return { props: { user, checkIn: checkIn.data } }
} catch (err) {
return {
notFound: true,
}
}
}) satisfies GetServerSideProps<{ user: SnAccount; checkIn: SnCheckInRecord[] }>
export default function UserProfile({ user, checkIn }: InferGetServerSidePropsType<typeof getServerSideProps>) {
return (
<>
{user.banner && (
<Box sx={{ aspectRatio: 16 / 5, position: 'relative' }}>
<Image src={getAttachmentUrl(user.banner)} alt="account banner" style={{ objectFit: 'cover' }} fill />
</Box>
)}
<Container sx={{ mt: 4, px: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', gap: 2 }}>
{user && <Avatar src={getAttachmentUrl(user.avatar)} />}
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography fontWeight="bold">{user.nick}</Typography>
<Typography fontFamily="monospace" fontSize={13} lineHeight={1.2}>
@{user.name}
</Typography>
</Box>
</Box>
</Box>
<Grid container spacing={2} sx={{ mt: 3 }}>
<Grid size={{ xs: 12, sm: 12, md: 8 }}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Fortune History
</Typography>
<LineChart
yAxis={[
{
data: [1, 2, 3, 4, 5],
tickMinStep: 1,
tickMaxStep: 1,
valueFormatter(value, _) {
const resultTierList = ['大凶', '凶', '中平', '吉', '大吉']
return resultTierList[value]
},
},
]}
xAxis={[
{
scaleType: 'band',
data: checkIn.map((c) => {
const og = new Date(c.createdAt)
og.setHours(0, 0, 0, 0)
return og
}),
valueFormatter(value, _) {
return new Date(value).toLocaleDateString('en-US', {
month: '2-digit',
day: '2-digit',
})
},
},
]}
series={[
{
data: checkIn.map((c) => c.resultTier),
valueFormatter(value, _) {
const resultTierList = ['大凶', '凶', '中平', '吉', '大吉']
return resultTierList[value ?? 0]
},
},
]}
height={300}
margin={{ top: 16, bottom: 24 }}
/>
</CardContent>
</Card>
</Grid>
<Grid
size={{ xs: 12, sm: 12, md: 4 }}
order={{ xs: -1, sm: -1, md: 1 }}
sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}
>
{user.badges && (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Badges
</Typography>
<Box display="flex" flexDirection="column" gap={0.5}>
{user.badges.map((b) => (
<Box sx={{ display: 'flex', gap: 2, alignItems: 'start' }}>
{SnAccountBadgeMapping[b.type].icon}
<Box>
<Typography variant="body2">{SnAccountBadgeMapping[b.type].name}</Typography>
{b.metadata.title && <Typography variant="subtitle2">{b.metadata.title}</Typography>}
</Box>
</Box>
))}
</Box>
</CardContent>
</Card>
)}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Information
</Typography>
{user.description && (
<Typography variant="body1" gutterBottom>
{user.description}
</Typography>
)}
<Typography variant="body2">
Born on {new Date(user.profile!.birthday!).toLocaleDateString()}
</Typography>
<Typography variant="body2" gutterBottom>
Joined at {new Date(user.createdAt).toLocaleDateString()}
</Typography>
<Typography variant="overline" lineHeight={1} fontFamily="monospace">
#{user.id.toString().padStart(8, '0')}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Container>
</>
)
}

64
src/pages/users/me.tsx Normal file
View File

@ -0,0 +1,64 @@
import { checkAuthenticatedClient, redirectToLogin } from '@/services/auth'
import { useUserStore } from '@/services/user'
import { Avatar, Box, Button, Container, Typography } from '@mui/material'
import { getAttachmentUrl } from '@/services/network'
import { useEffect } from 'react'
import Image from 'next/image'
import LogoutIcon from '@mui/icons-material/Logout'
import LaunchIcon from '@mui/icons-material/Launch'
import Link from 'next/link'
import { deleteCookie } from 'cookies-next/client'
export default function UserItself() {
useEffect(() => {
if (!checkAuthenticatedClient()) redirectToLogin()
}, [])
const userStore = useUserStore()
function logout() {
deleteCookie('nex_user_atk')
deleteCookie('nex_user_rtk')
window.location.reload()
}
return (
<>
{userStore.account?.banner && (
<Box sx={{ aspectRatio: 16 / 5, position: 'relative' }}>
<Image src={getAttachmentUrl(userStore.account!.banner)} alt="account banner" objectFit="cover" fill />
</Box>
)}
<Container sx={{ mt: 4, px: 2 }}>
<Typography variant="h5" component="h1">
Your Solarpass
</Typography>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 2 }}>
<Box sx={{ display: 'flex', gap: 2 }}>
{userStore.account && <Avatar src={getAttachmentUrl(userStore.account.avatar)} />}
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography fontWeight="bold">{userStore.account?.nick}</Typography>
<Typography fontFamily="monospace" fontSize={13} lineHeight={1.2}>
@{userStore.account?.name}
</Typography>
</Box>
</Box>
</Box>
<Box sx={{ mt: 3, display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<Link passHref href="https://sn.solsynth.dev/account" target="_blank">
<Button variant="contained" color="primary" startIcon={<LaunchIcon />}>
Open in Solian
</Button>
</Link>
<Button variant="contained" color="error" startIcon={<LogoutIcon />} onClick={logout}>
Logout
</Button>
</Box>
</Container>
</>
)
}

View File

@ -1,39 +0,0 @@
---
import Layout from '@/layouts/Layout.astro'
---
<Layout title="Terms & Conditions">
<div
class="container max-w-[85ch] mx-auto mt-[25vh] px-2 flex flex-col gap-4"
>
<h1 class="text-2xl font-bold">条款和条件</h1>
<p>这里罗列了您使用我们的产品将需要同意的所有条款和条件。</p>
<p>
我们正努力让事情尽可能简单。这对我们双方都有好处。通常情况下你不需要关心这件事。只是为了让我们的律师高兴。
<i>我们真的有律师吗?</i>
</p>
<p>此处的信息可能会不时更改。请刷新 查看此页面以获取最新版本。</p>
<div class="flex flex-col gap-4 mt-4 mx-[-16px]">
<a href="/terms/user-agreements">
<div class="card bg-base-100 w-full border-neutral border">
<div class="card-body">
<h2 class="card-title">用户协议</h2>
<p>使用 Solar Network 和 我们其他产品的用户须遵守的用户协议。</p>
</div>
</div>
</a>
<a href="/terms/privacy-policy">
<div class="card bg-base-100 w-full border-neutral border">
<div class="card-body">
<h2 class="card-title">隐私政策</h2>
<p>隐私政策向我们展示了如何处理和处理您提供的数据。</p>
</div>
</div>
</a>
</div>
</div>
</Layout>

View File

@ -1,51 +0,0 @@
---
import Layout from '@/layouts/Layout.astro'
---
<Layout title="User Agreements">
<div
class="container max-w-[85ch] mx-auto mt-[25vh] px-12 flex flex-col gap-4"
>
<h1 class="text-4xl font-bold">隐私政策</h1>
<article class="prose prose-lg max-w-none mt-5">
<h2 id="introduction">简介</h2>
<p>
我们非常重视您的隐私。本隐私政策概述了我们收集的个人信息类型、使用方式以及我们采取的保护措施。
</p>
<h2 id="information-collection">信息收集</h2>
<p>
我们仅在提供服务时收集必要的个人信息。这可能包括您的姓名、电子邮件地址以及其他相关信息。
</p>
<h2 id="use-of-information">信息使用</h2>
<p>我们使用您的个人信息来:</p>
<ul>
<li>提供和改进我们的服务</li>
<li>与您沟通更新或重要信息</li>
<li>确保遵守法律义务</li>
</ul>
<h2 id="data-sharing">数据共享</h2>
<p>我们不会出售、交易或与第三方分享您的个人信息,法律要求除外。</p>
<h2 id="data-security">数据安全</h2>
<p>
我们实施了强有力的安全措施,以保护您的个人信息免受未经授权的访问、更改、披露或销毁。
</p>
<h2 id="your-rights">您的权利</h2>
<p>您有权:</p>
<ul>
<li>访问我们持有的关于您的个人信息</li>
<li>请求更正您的个人信息</li>
<li>请求删除您的个人信息</li>
</ul>
<h2 id="contact-us">联系我们</h2>
<p>
如果您对本隐私政策或我们的数据处理方式有任何疑问或顾虑,请通过
lily@solsynth.dev 与我们联系。
</p>
<h2 id="changes-to-this-policy">政策变更</h2>
<p>
我们可能会不时更新本隐私政策。任何更改将发布在此页面上,且我们会通知您任何重大更改。
</p>
</article>
</div>
</Layout>

View File

@ -1,101 +0,0 @@
---
import Layout from '@/layouts/Layout.astro'
---
<Layout title="User Agreements">
<div
class="container max-w-[85ch] mx-auto mt-[25vh] px-12 flex flex-col gap-4"
>
<h1 class="text-4xl font-bold">用户协议</h1>
<article class="prose prose-lg max-w-none mt-5">
<p>
本协议适用于所有 Solsynth LLC 的产品,包括但不限于 Solar
Network、Solian、DietaryGuard、AceField。
</p>
<h2 id="provision-and-discontinuance-of-service">服务的提供和停止</h2>
<p>
Solsynth LLC 将向世界上所有的生物提供同等的服务,包括草履虫。
同时也保留向任意用户停止提供服务的权利。关于停止部分用户的服务,我们不需要提前通知。
</p>
<h2 id="user-generated-content">用户生成内容</h2>
<p>
任意发布在 Solar Network
上的内容(包括但不限于帖子、文章、附件)都默认授权 Solsynth LLC
予以展示的权利。
除非用户特别声明,所有内容均为原帖主保留所有权利,转载请先向原帖主授权。
</p>
<h3 id="reproduction-recognition">转载的认定</h3>
<p>无帖主特别声明,所有内容均适用本条转载的定义。</p>
<p>
转载指将原帖的内容原封不动或略作改动上传到别的平台或 Solar
Network。但同时转帖、嵌入式组件与展示展开的链接不构成转载。
转载即时在原帖主授权的情况下也需表明出处。
</p>
<h3 id="freedom-of-speech">言论的自由</h3>
<p>
除滥用资源的情况,我们不会将用户生成内容进行删除。也不会做出要求任何用户删除任何内容的要求。
</p>
<p>
但 Solsynth LLC
始终保留对于违反社区准则的内容(如淫秽、暴力、血腥、反社会、恐怖组织等)限制与停止向公众展示的权利。
</p>
<p>
尽管在 Solar Network 上你拥有 100%
的言论自由。但还请清楚,言论自由不代表不用对自己的言论负责。
</p>
<h4 id="restriction-and-discontinuation">限制展示与停止展示</h4>
<ul>
<li>
<p>
限制展示:停止相关的推送,但是任保留直接通过资源标识符和分享连接访问的权利
</p>
</li>
<li><p>停止展示:全面停止除作者之外任何人访问该资源的权利</p></li>
</ul>
<h2 id="resource-misuse-prevention-policy">防止资源滥用条例</h2>
<p>
尽管使用 Solar Network
的数据托管服务并无任何的容量限制,但经过判定的滥用资源将会被取消使用部分功能的权利。
并且之前上传的资源 Solsynth LLC 有权对其进行删除空间回收。
</p>
<h3 id="determination-of-misuse">滥用的认定</h3>
<ul>
<li>
传而不用:例如在 Solar Network 的 Interactive
附件池中过度上传附件并不将附件与帖子连接
</li>
<li>无意义帖:无意义洗版或浪费 Solar Network 的存储资源</li>
<li>
走错片场:将 Solar Network
公有资源当作自己的专用资源池使用(详见维基《专用资源池》页面)
</li>
</ul>
<p>滥用的认定最终解释权归属于 Solsynth Trust &amp; Safety Team</p>
<h2 id="secondary-releases">二次发布</h2>
<p>二次发布指将我们的资产下载并重新托管到别站。</p>
<h3 id="product-secondary-release">制品二次发布</h3>
<p>
除特殊声明Solsynth LLC
的产品均不允许二次发布,请勿将我们的产品构建下载并二次上传于其他站点。
<strong>二次作为商用发布更是不允许的。</strong>
</p>
<p>
你应该做的是将我们的产品链接贴上他站。或使用嵌入式组件。并且表明
Solsynth LLC 版权所有。
</p>
<p>若您想搭建我们制品的镜像站,请与我们取得联系以豁免此条例。</p>
<h3 id="secondary-distribution-of-source-code">源码二次发布</h3>
<p>我们不允许任何形式的源码二次发布Fork 除外)。</p>
<p>
包括但不限于,将 GitHub 或 Solsynth Code Repository 上的代码仓库镜像于
GitLab、Gitee 等其他 Git 提供者。
<strong>二次售卖源码更是不允许的。</strong>
</p>
<p>关于更多的源码使用条例,请遵循项目使用的开源许可证。</p>
<p>若您想搭建我们源码的镜像站,请与我们取得联系以豁免此条例。</p>
<hr />
<p>Solsynth LLC 保留对此协议的最终解释权</p>
</article>
</div>
</Layout>

View File

@ -1,26 +0,0 @@
export function getAttachmentUrl(identifier?: string): string {
if (!identifier) return ''
if (identifier.startsWith('http')) {
return identifier
}
const baseUrl = import.meta.env.PUBLIC_SOLAR_NETWORK_URL
return `${baseUrl}/cgi/uc/attachments/${identifier}`
}
export async function fetchAttachmentMeta(
identifiers: string[]
): Promise<any[]> {
if (!identifiers) return []
const baseUrl = import.meta.env.PUBLIC_SOLAR_NETWORK_URL
const resp = await fetch(
`${baseUrl}/cgi/uc/attachments?take=${
identifiers.length
}&id=${identifiers.join(',')}`
)
if (resp.status !== 200) {
throw new Error(`Failed to fetch attachment meta: ${await resp.text()}`)
}
const out = await resp.json()
return out['data']
}

View File

@ -0,0 +1,48 @@
import { sni } from './network'
export interface SnAttachment {
id: number
createdAt: Date
updatedAt: Date
deletedAt?: Date | null
rid: string
uuid: string
size: number
name: string
alt: string
mimetype: string
hash: string
destination: number
refCount: number
contentRating: number
qualityRating: number
cleanedAt?: Date | null
isAnalyzed: boolean
isSelfRef: boolean
isIndexable: boolean
ref?: SnAttachment | null
refId?: number | null
poolId?: number | null
accountId: number
thumbnailId?: number | null
thumbnail?: SnAttachment | null
compressedId?: number | null
compressed?: SnAttachment | null
usermeta: Record<string, any>
metadata: Record<string, any>
}
export async function getAttachment(id: string | number): Promise<SnAttachment> {
const resp = await sni.get<SnAttachment>('/cgi/uc/attachments/' + id + '/meta')
return resp.data
}
export async function listAttachment(id: string[]): Promise<SnAttachment[]> {
const resp = await sni.get<{ data: SnAttachment[] }>('/cgi/uc/attachments', {
params: {
id: id.join(','),
take: id.length,
},
})
return resp.data.data
}

44
src/services/auth.ts Normal file
View File

@ -0,0 +1,44 @@
import { hasCookie } from 'cookies-next/client'
export interface SnAuthResult {
isFinished: boolean
ticket: SnAuthTicket
}
export interface SnAuthTicket {
id: number
createdAt: Date
updatedAt: Date
deletedAt?: Date | null
stepRemain: number
grantToken?: string | null
accessToken?: string | null
refreshToken?: string | null
ipAddress: string
location: string
userAgent: string
expiredAt?: Date | null
lastGrantAt?: Date | null
availableAt?: Date | null
nonce?: string | null
accountId?: number | null
factorTrail: number[]
}
export interface SnAuthFactor {
id: number
createdAt: Date
updatedAt: Date
deletedAt?: Date | null
type: number
config?: Record<string, any> | null
accountId?: number | null
}
export function checkAuthenticatedClient(): boolean {
return !!hasCookie('nex_user_atk')
}
export function redirectToLogin() {
window.open('/auth/login?redirect_uri=' + encodeURIComponent(window.location.pathname), '_self')
}

10
src/services/checkIn.ts Normal file
View File

@ -0,0 +1,10 @@
export interface SnCheckInRecord {
id: number
createdAt: Date
updatedAt: Date
deletedAt?: Date | null
resultTier: number
resultExperience: number
resultModifiers: number[]
accountId: number
}

77
src/services/network.ts Normal file
View File

@ -0,0 +1,77 @@
import axios, { AxiosInstance } from 'axios'
import applyCaseMiddleware from 'axios-case-converter'
import { hasCookie, getCookie, setCookie } from 'cookies-next/client'
const baseURL = 'https://api.sn.solsynth.dev'
export let sni: AxiosInstance = (() => {
const inst = axios.create({
baseURL,
})
inst.interceptors.request.use(
async (config) => {
const tk = await refreshToken()
if (tk) config.headers['Authorization'] = `Bearer ${tk}`
return config
},
(error) => error,
)
applyCaseMiddleware(inst, {
ignoreParams: true,
ignoreHeaders: true,
})
return inst
})()
async function refreshToken(): Promise<string | undefined> {
if (!hasCookie('nex_user_atk') || !hasCookie('nex_user_rtk')) return
const ogTk: string = getCookie('nex_user_atk')!
if (!isTokenExpired(ogTk)) return ogTk
const resp = await axios.post(
'/cgi/id/auth/token',
{
refresh_token: getCookie('nex_user_rtk')!,
grant_type: 'refresh_token',
},
{ baseURL },
)
const atk: string = resp.data['access_token']
const rtk: string = resp.data['refresh_token']
setCookie('nex_user_atk', atk, { path: '/', maxAge: 2592000 })
setCookie('nex_user_rtk', rtk, { path: '/', maxAge: 2592000 })
console.log('[Authenticator] Refreshed token...')
return atk
}
function isTokenExpired(token: string): boolean {
try {
const parts = token.split('.')
if (parts.length !== 3) {
throw new Error('Invalid JWT format')
}
const payload = JSON.parse(atob(parts[1]))
if (!payload.exp) {
throw new Error("'exp' claim is missing in the JWT payload")
}
const now = Math.floor(Date.now() / 1000)
return now >= payload.exp
} catch (error) {
console.error('[Authenticator] Something went wrong with token: ', error)
return true
}
}
export function getAttachmentUrl(identifer: string): string {
if (identifer.startsWith('http')) return identifer
return `${baseURL}/cgi/uc/attachments/${identifer}`
}

85
src/services/post.ts Normal file
View File

@ -0,0 +1,85 @@
export interface SnPost {
id: number
createdAt: Date
updatedAt: Date
deletedAt?: Date | null
type: string
body: SnPostBody & Record<string, any>
language: string
alias?: string | null
aliasPrefix?: string | null
tags: SnPostTag[]
categories: SnPostCategory[]
replies?: SnPost[] | null
replyId?: number | null
repostId?: number | null
replyTo?: SnPost | null
repostTo?: SnPost | null
visibleUsersList?: number[] | null
invisibleUsersList?: number[] | null
visibility: number
editedAt?: Date | null
pinnedAt?: Date | null
lockedAt?: Date | null
isDraft: boolean
publishedAt?: Date | null
publishedUntil?: Date | null
totalUpvote: number
totalDownvote: number
publisherId: number
publisher: SnPublisher
metric: SnMetric
}
export interface SnPostTag {
id: number
createdAt: Date
updatedAt: Date
deletedAt?: Date
alias: string
name: string
description: string
posts?: SnPost[]
}
export interface SnPostCategory {
id: number
createdAt: Date
updatedAt: Date
deletedAt?: Date
alias: string
name: string
description: string
posts?: SnPost[]
}
export interface SnPostBody {
attachments: string[]
content: string
location?: string
thumbnail?: string
title?: string
}
export interface SnMetric {
replyCount: number
reactionCount: number
reactionList: Record<string, number>
}
export interface SnPublisher {
id: number
createdAt: Date
updatedAt: Date
deletedAt?: Date | null
type: number
name: string
nick: string
description: string
avatar: string
banner: string
totalUpvote: number
totalDownvote: number
realmId?: number | null
accountId: number
}

97
src/services/user.tsx Normal file
View File

@ -0,0 +1,97 @@
import { create } from 'zustand'
import { sni } from './network'
import { hasCookie } from 'cookies-next/client'
import { JSX } from 'react'
import ConstructionIcon from '@mui/icons-material/Construction'
import FlagIcon from '@mui/icons-material/Flag'
export interface SnAccount {
id: number
createdAt: Date
updatedAt: Date
deletedAt?: Date | null
confirmedAt?: Date | null
contacts?: SnAccountContact[] | null
avatar: string
banner: string
description: string
name: string
nick: string
permNodes: Record<string, any>
profile?: SnAccountProfile | null
badges: SnAccountBadge[]
suspendedAt?: Date | null
affiliatedId?: number | null
affiliatedTo?: number | null
automatedBy?: number | null
automatedId?: number | null
}
export interface SnAccountContact {
accountId: number
content: string
createdAt: Date
deletedAt?: Date | null
id: number
isPrimary: boolean
isPublic: boolean
type: number
updatedAt: Date
verifiedAt?: Date | null
}
export interface SnAccountProfile {
id: number
accountId: number
birthday?: Date | null
createdAt: Date
deletedAt?: Date | null
experience: number
firstName: string
lastName: string
lastSeenAt?: Date | null
updatedAt: Date
}
export interface SnAccountBadge {
id: number
createdAt: Date
updatedAt: Date
deletedAt?: Date | null
type: string
accountId: number
metadata: Record<string, any>
}
export const SnAccountBadgeMapping: Record<string, { icon: JSX.Element; name: string }> = {
'company.staff': {
icon: <ConstructionIcon />,
name: 'Solsynth Staff',
},
'site.migration': {
icon: <FlagIcon />,
name: 'Solar Network Natives',
},
}
export interface UserStore {
account: SnAccount | undefined
fetchUser: () => Promise<SnAccount | undefined>
}
export const useUserStore = create<UserStore>((set) => ({
account: undefined,
fetchUser: async (): Promise<SnAccount | undefined> => {
if (!hasCookie('nex_user_atk')) return
try {
const resp = await sni.get<SnAccount>('/cgi/id/users/me')
set({ account: resp.data })
console.log('[Authenticator] Logged in as @' + resp.data.name)
return resp.data
} catch (err) {
console.error('[Authenticator] Unable to get user profile: ', err)
return
}
},
}))

38
src/styles/globals.css Normal file
View File

@ -0,0 +1,38 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.textmarker-effect {
position: relative;
}
.textmarker-effect:before {
position: absolute;
z-index: -1;
content: '';
background: var(--mui-palette-primary-main);
height: 20px;
left: 0;
bottom: 0;
width: 0%;
opacity: 0.5;
transition: all 0.5s;
}
.textmarker-effect:hover:before {
width: 100%;
}
.textmarker-effect.active:before {
animation: textmarker-effect-animation 0.5s ease-in-out;
animation-fill-mode: forwards;
}
@keyframes textmarker-effect-animation {
0% {
width: 0;
}
100% {
width: 100%;
}
}

View File

@ -1,38 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
theme: {
extend: {},
},
plugins: [require('@tailwindcss/typography'), require('daisyui')],
daisyui: {
themes: [
{
light: {
primary: '#3f51b5',
secondary: '#4ba6ee',
accent: '#03a9f4',
neutral: '#4b5563',
'base-100': '#ffffff',
info: '#4994ec',
success: '#67ad5b',
warning: '#f5c344',
error: '#e15241',
},
},
{
dark: {
primary: '#3f51b5',
secondary: '#4ba6ee',
accent: '#03a9f4',
neutral: '#1f2937',
'base-100': '#000011',
info: '#4994ec',
success: '#67ad5b',
warning: '#f5c344',
error: '#e15241',
},
},
],
},
}

18
tailwind.config.ts Normal file
View File

@ -0,0 +1,18 @@
import type { Config } from 'tailwindcss'
export default {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
background: 'var(--background)',
foreground: 'var(--foreground)',
},
},
},
plugins: [require('@tailwindcss/typography')],
} satisfies Config

View File

@ -1,17 +1,22 @@
{
"extends": "astro/tsconfigs/strict",
"include": [
".astro/types.d.ts",
"**/*",
"src/**/*.d.ts",
"src/**/*.ts",
"src/**/*.astro"
],
"exclude": ["dist"],
"compilerOptions": {
"baseUrl": ".",
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": ["src/*"]
}
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}