40 Commits

Author SHA1 Message Date
654d5fb191 📝 Update download links 2025-02-23 19:10:55 +08:00
eafb99c1cb 📝 Update solar network download link 2025-02-16 00:29:12 +08:00
5718faea5d 📝 Impersonation part of user agreement 2025-02-14 23:50:27 +08:00
bf5fb6f259 📈 Replace GA with Umami 2025-02-13 21:05:31 +08:00
b65a87e38b 🐛 Fix joining failed joining one of channel fail will stop entire process 2025-02-10 22:28:58 +08:00
05de2e1799 Realm QR Code 2025-02-10 18:59:04 +08:00
f26d29e2f0 Join realm link 2025-02-10 18:11:25 +08:00
931c917ba7 📝 Update solar network introduce 2025-02-06 19:59:57 +08:00
5973c7f25e 🌐 Localize solar network intro 2025-02-06 17:53:28 +08:00
35cb92b322 🗑️ Remove lunar new year count down 2025-02-03 17:11:16 +08:00
8bfd19f7e3 Pay of order 2025-02-03 00:41:29 +08:00
80d3dac2b0 👽 Support other auth factors
🐛 Fix redirect url did not work
2025-02-02 22:31:46 +08:00
7600e3f93d 🐛 Fix confirm account flow cannot get code from qs 2025-01-27 18:14:57 +08:00
dad48b7a60 🐛 Fix font compieling errors 2025-01-24 17:43:16 +08:00
5f07990ff2 📈 Add google analytics 2025-01-24 17:39:52 +08:00
102e14f643 2025 Lunar New Year Countdown Basis 2025-01-20 22:41:45 +08:00
4682aa000f ♻️ Remove vercel stuff 2025-01-14 18:45:34 +08:00
45dd0ebcb9 🧱 Using RoadSign instead of Vercel to deploy 2025-01-14 18:39:25 +08:00
e6822c0009 🐛 Fix cannot edit release 2025-01-12 00:40:38 +08:00
2e951a084e Editing release runner 2025-01-11 20:49:32 +08:00
7919a854df 🐛 Bug fixes 2025-01-11 15:51:53 +08:00
45c871cb06 Able to set product icon and previews 2025-01-11 15:51:17 +08:00
7e0dff9f0f 🐛 Fix payload case issue 2025-01-11 15:36:17 +08:00
bf43234eae 🐛 Fix page title error 2025-01-11 15:31:01 +08:00
cdf34ee9a1 Attachment uploading 2025-01-11 15:28:23 +08:00
bdcbb18592 Supports release assets & installers 2025-01-11 13:58:27 +08:00
847151afe8 🐛 Bug fixes on solar js sdk 2025-01-11 12:38:34 +08:00
35222ab4e4 🐛 Fix missing key id 2025-01-11 00:14:53 +08:00
79e7709cc1 Able to delete product 2025-01-11 00:12:03 +08:00
9a265c9887 Create & edit release 2025-01-11 00:02:16 +08:00
10191c04e0 🗑️ Clean up code 2025-01-10 23:12:58 +08:00
a69362470f 🐛 Bug fixes 2025-01-10 23:03:08 +08:00
b5765877af Add upload attachment ability into sdk 2025-01-10 21:40:59 +08:00
d8f51e305b Edit product
♻️ Extract form into a single widget
2025-01-10 19:57:10 +08:00
9cc8cf999e 🐛 Fix vercel build error by replacing solar-js-sdk with remote one 2025-01-10 00:33:29 +08:00
5d6a018c63 🐛 Switch bundler and bug fixes 2025-01-10 00:26:52 +08:00
c1140b4e2f ♻️ Splitting up services (skip ci) 2025-01-10 00:18:26 +08:00
df6679bbe3 💄 Optimize card of product 2025-01-09 23:42:37 +08:00
945bd5d357 Matrix console create product 2025-01-09 23:37:24 +08:00
daba03c2e8 Basic console layout 2025-01-09 22:33:52 +08:00
80 changed files with 3048 additions and 328 deletions

8
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

12
.idea/Capital.iml generated Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

61
.idea/codeStyles/Project.xml generated Normal file
View File

@ -0,0 +1,61 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<HTMLCodeStyleSettings>
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
<VueCodeStyleSettings>
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="120" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="SOFT_MARGINS" value="120" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="SOFT_MARGINS" value="120" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="120" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="2" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

5
.idea/codeStyles/codeStyleConfig.xml generated Normal file
View File

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

10
.idea/material_theme_project_new.xml generated Normal file
View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MaterialThemeProjectNewConfig">
<option name="metadata">
<MTProjectMetadataState>
<option name="userId" value="14beee28:194cb09ea37:-7ffd" />
</MTProjectMetadataState>
</option>
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Capital.iml" filepath="$PROJECT_DIR$/.idea/Capital.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

13
.roadsignrc Normal file
View File

@ -0,0 +1,13 @@
{
"sync": {
"region": "capital",
"configPath": "roadsign.toml"
},
"deployments": [
{
"region": "capital",
"site": "capital-app",
"path": ".next"
}
]
}

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"cSpell.words": [
"Testflight"
]
}

BIN
bun.lockb

Binary file not shown.

7
next-i18next.config.js Normal file
View File

@ -0,0 +1,7 @@
/** @type {import('next-i18next').UserConfig} */
module.exports = {
i18n: {
defaultLocale: 'en-US',
locales: ['en-US', 'zh-CN'],
},
}

View File

@ -1,8 +1,12 @@
import type { NextConfig } from 'next'
import { i18n } from './next-i18next.config'
const nextConfig: NextConfig = {
/* config options here */
i18n,
reactStrictMode: true,
output: 'standalone',
generateBuildId: async () => {
return process.env.GIT_HASH ?? 'development'
},

View File

@ -5,6 +5,7 @@
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"build-standalone": "next build && cp -r .next/static .next/standalone/.next/ && cp -r public .next/standalone",
"start": "next start",
"lint": "next lint"
},
@ -13,22 +14,28 @@
"@emotion/react": "^11.14.0",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.14.0",
"@monaco-editor/react": "^4.6.0",
"@mui/icons-material": "^6.3.1",
"@mui/material": "^6.3.1",
"@mui/material-nextjs": "^6.3.1",
"@mui/x-charts": "^7.23.2",
"@mui/x-charts": "^7.23.6",
"@next/third-parties": "^15.1.6",
"@tailwindcss/typography": "^0.5.16",
"@vercel/speed-insights": "^1.1.0",
"@toolpad/core": "^0.11.0",
"animate.css": "^4.1.1",
"axios": "^1.7.9",
"axios-case-converter": "^1.1.1",
"cookies-next": "^5.0.2",
"feed": "^4.2.2",
"next": "15.1.3",
"i18next": "^24.2.2",
"next": "^15.1.5",
"next-i18next": "^15.4.2",
"next-nprogress-bar": "^2.4.3",
"qrcode.react": "^4.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-i18next": "^15.4.0",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1",
"remark-breaks": "^4.0.0",
@ -36,21 +43,26 @@
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.1",
"sitemap": "^8.0.0",
"solar-js-sdk": "^0.1.3",
"unified": "^11.0.5",
"zustand": "^5.0.3"
},
"devDependencies": {
"typescript": "^5.7.3",
"@eslint/eslintrc": "^3.2.0",
"@types/node": "^20.17.12",
"@types/react": "^19.0.4",
"@types/react-dom": "^19.0.2",
"daisyui": "^4.12.23",
"eslint": "^9.18.0",
"eslint-config-next": "15.1.3",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"eslint": "^9.17.0",
"eslint-config-next": "15.1.3",
"@eslint/eslintrc": "^3.2.0"
"typescript": "^5.7.3"
},
"trustedDependencies": [
"@vercel/speed-insights"
"@vercel/speed-insights",
"core-js",
"esbuild",
"sharp"
]
}

175
packages/sn/.gitignore vendored Normal file
View File

@ -0,0 +1,175 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Caches
.cache
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

BIN
packages/sn/bun.lockb Executable file

Binary file not shown.

37
packages/sn/package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "solar-js-sdk",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"entrypoint": "dist/index.js",
"type": "module",
"author": {
"name": "LittleSheep",
"email": "littlesheep.code@hotmail.com"
},
"version": "0.1.3",
"tsup": {
"entry": [
"src/index.ts"
],
"splitting": true,
"sourcemap": true,
"clean": true,
"dts": true,
"format": "esm"
},
"scripts": {
"build": "tsup"
},
"devDependencies": {
"@types/bun": "latest",
"tsup": "^8.3.5"
},
"peerDependencies": {
"typescript": "^5.0.0"
},
"dependencies": {
"axios": "^1.7.9",
"universal-cookie": "^7.2.2",
"zustand": "^5.0.3"
}
}

View File

@ -0,0 +1,190 @@
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
}
export type MultipartProgress = {
value: number | null
current: number
total: number
}
export type MultipartInfo = {
rid: string
fileChunks: Record<string, number>
}
export class UploadAttachmentTask {
private content: File
private pool: string
private multipartSize: number = 0
private multipartInfo: MultipartInfo | null = null
private multipartProgress: MultipartProgress = { value: null, current: 0, total: 0 }
onProgress?: (progress: MultipartProgress) => void
onSuccess?: (success: boolean) => void
onError?: (error: string) => void
constructor(content: File, pool: string) {
if (!content || !pool) {
throw new Error('Content and pool are required.')
}
this.content = content
this.pool = pool
}
public async submit(): Promise<SnAttachment> {
const limit = 3
try {
await this.createFragment()
console.log(`[Paperclip] Multipart placeholder has been created with rid ${this.multipartInfo?.rid}`)
this.multipartProgress.value = 0
this.multipartProgress.current = 0
const chunks = Object.keys(this.multipartInfo?.fileChunks || {})
this.multipartProgress.total = chunks.length
let result: SnAttachment | null = null
const uploadChunks = async (chunk: string): Promise<void> => {
try {
const resp = await this.uploadOneChunk(chunk)
this.multipartProgress.current++
console.log(
`[Paperclip] Uploaded multipart ${this.multipartProgress.current}/${this.multipartProgress.total}`,
)
this.multipartProgress.value = this.multipartProgress.current / this.multipartProgress.total
if (this.onProgress) this.onProgress(this.multipartProgress)
result = resp
} catch (err) {
console.log(`[Paperclip] Upload multipart ${chunk} failed, retrying in 3 seconds...`)
await this.delay(3000)
await uploadChunks(chunk)
}
}
for (let i = 0; i < chunks.length; i += limit) {
const chunkSlice = chunks.slice(i, i + limit)
await Promise.all(chunkSlice.map(uploadChunks))
}
console.log(`[Paperclip] Entire file has been uploaded in ${this.multipartProgress.total} chunk(s)`)
if (this.onSuccess) this.onSuccess(true)
return result!
} catch (err: any) {
if (this.onError) this.onError(err.toString())
throw err
}
}
private async createFragment(): Promise<void> {
const mimetypeMap: Record<string, string> = {
mp4: 'video/mp4',
mov: 'video/quicktime',
mp3: 'audio/mp3',
wav: 'audio/wav',
m4a: 'audio/m4a',
}
const fileExtension = this.content.name.split('.').pop() || ''
const mimetype = mimetypeMap[fileExtension]
const nameArray = this.content.name.split('.')
nameArray.pop()
const resp = await sni.post('/cgi/uc/fragments', {
pool: this.pool,
size: this.content.size,
name: this.content.name,
alt: nameArray.join('.'),
mimetype,
})
const data = await resp.data
this.multipartSize = data.chunkSize
this.multipartInfo = data.meta
}
private async uploadOneChunk(chunkId: string): Promise<SnAttachment | null> {
if (!this.multipartInfo) return null
const chunkIdx = this.multipartInfo.fileChunks[chunkId]
const chunk = this.content.slice(chunkIdx * this.multipartSize, (chunkIdx + 1) * this.multipartSize)
const resp = await sni.post(`/cgi/uc/fragments/${this.multipartInfo.rid}/${chunkId}`, chunk, {
headers: { 'Content-Type': 'application/octet-stream' },
timeout: 3 * 60 * 1000,
})
if (resp.data['attachment']) {
return resp.data['attachment'] as SnAttachment
}
this.multipartInfo = resp.data['fragment']
return null
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
public static formatBytes(bytes: number, decimals = 2): string {
if (!+bytes) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`
}
}

View File

@ -1,4 +1,4 @@
import { hasCookie } from 'cookies-next/client'
import Cookies from 'universal-cookie'
export interface SnAuthResult {
isFinished: boolean
@ -35,8 +35,21 @@ export interface SnAuthFactor {
accountId?: number | null
}
export function setTokenCookies(atk: string, rtk: string) {
const cookies = new Cookies()
cookies.set('nex_user_atk', atk, { path: '/', maxAge: 2592000 })
cookies.set('nex_user_rtk', rtk, { path: '/', maxAge: 2592000 })
}
export function removeTokenCookies() {
const cookies = new Cookies()
cookies.remove('nex_user_atk')
cookies.remove('nex_user_rtk')
}
export function checkAuthenticatedClient(): boolean {
return !!hasCookie('nex_user_atk')
const cookies = new Cookies()
return !!cookies.get('nex_user_atk')
}
export function redirectToLogin() {

8
packages/sn/src/index.ts Normal file
View File

@ -0,0 +1,8 @@
export * from './matrix/product'
export * from './matrix/release'
export * from './attachment'
export * from './auth'
export * from './checkIn'
export * from './network'
export * from './post'
export * from './user'

View File

@ -0,0 +1,25 @@
export interface MaProduct {
id: number
created_at: Date
updated_at: Date
deleted_at?: Date
icon: string
name: string
alias: string
description: string
previews: string[]
tags: string[]
meta: MaProductMeta
releases: null
account_id: number
}
export interface MaProductMeta {
id: number
created_at: Date
updated_at: Date
deleted_at?: Date
introduction: string
attachments: string[]
product_id: number
}

View File

@ -0,0 +1,48 @@
export interface MaRelease {
id: number
created_at: Date
updated_at: Date
deleted_at?: Date
version: string
type: number
channel: string
assets: Record<string, MaReleaseAsset>
installers: Record<string, MaReleaseInstaller>
runners: Record<string, MaReleaseRunner>
product_id: number
meta: MaReleaseMeta
}
export interface MaReleaseMeta {
id: number
created_at: Date
updated_at: Date
deleted_at?: Date
title: string
description: string
content: string
attachments: string[]
release_id: number
}
export interface MaReleaseAsset {
uri: string
contentType: string
}
export interface MaReleaseInstallerPatch {
action: string
glob: string
}
export interface MaReleaseInstaller {
workdir?: string
script?: string
patches: MaReleaseInstallerPatch[]
}
export interface MaReleaseRunner {
workdir?: string
script: string
label: string
}

126
packages/sn/src/network.ts Normal file
View File

@ -0,0 +1,126 @@
import axios, { type AxiosInstance } from 'axios'
import Cookies from 'universal-cookie'
import { setTokenCookies } from './auth'
function toCamelCase(obj: any): any {
if (Array.isArray(obj)) {
return obj.map(toCamelCase)
} else if (obj && typeof obj === 'object') {
return Object.keys(obj).reduce((result: any, key) => {
const camelKey = key.replace(/_([a-z])/g, (_, char) => char.toUpperCase())
result[camelKey] = toCamelCase(obj[key])
return result
}, {})
}
return obj
}
function toSnakeCase(obj: any): any {
if (Array.isArray(obj)) {
return obj.map(toSnakeCase)
} else if (obj && typeof obj === 'object') {
return Object.keys(obj).reduce((result: any, key) => {
const snakeKey = key.replace(/[A-Z]/g, (char) => `_${char.toLowerCase()}`)
result[snakeKey] = toSnakeCase(obj[key])
return result
}, {})
}
return obj
}
const baseURL = 'https://api.sn.solsynth.dev'
export const 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,
)
/// Case convertor
inst.interceptors.request.use(
(config) => {
if (config.data) {
config.data = toSnakeCase(config.data)
}
return config
},
(error) => Promise.reject(error),
)
inst.interceptors.response.use(
(response) => {
if (response.data) {
response.data = toCamelCase(response.data)
}
return response
},
(error) => {
if (error.response && error.response.data) {
error.response.data = toCamelCase(error.response.data)
}
return Promise.reject(error)
},
)
return inst
})()
async function refreshToken(): Promise<string | undefined> {
const cookies = new Cookies()
if (!cookies.get('nex_user_atk') || !cookies.get('nex_user_rtk')) return
const ogTk: string = cookies.get('nex_user_atk')!
if (!isTokenExpired(ogTk)) return ogTk
const resp = await axios.post(
'/cgi/id/auth/token',
{
refresh_token: cookies.get('nex_user_rtk')!,
grant_type: 'refresh_token',
},
{ baseURL },
)
const atk: string = resp.data['access_token']
const rtk: string = resp.data['refresh_token']
setTokenCookies(atk, rtk)
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}`
}

83
packages/sn/src/user.ts Normal file
View File

@ -0,0 +1,83 @@
import { create } from 'zustand'
import { sni } from './network'
import Cookies from 'universal-cookie'
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 interface UserStore {
account: SnAccount | undefined
fetchUser: () => Promise<SnAccount | undefined>
}
export const useUserStore = create<UserStore>((set) => ({
account: undefined,
fetchUser: async (): Promise<SnAccount | undefined> => {
const cookies = new Cookies()
if (!cookies.get('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
}
},
}))

27
packages/sn/tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

View File

@ -0,0 +1,16 @@
{
"actionDownload": "Download",
"actionLearnMore": "Learn more",
"downloadPlatform": "Platform",
"downloadDistribution": "Distribution",
"downloadAppleStore": "iOS / macOS (App Store)",
"downloadAppleTestflight": "iOS / macOS (TestFlight)",
"downloadAndroid": "Android",
"downloadWindows": "Windows",
"downloadWeb": "Web",
"downloadSourceCode": "Source Code",
"downloadLinux": "Linux Unpacked",
"downloadLinuxDebian": "deb (Debian/Ubuntu)",
"actionOpen": "Open",
"faq": "Frequently Asked Questions"
}

View File

@ -0,0 +1,35 @@
{
"appName": "Solar Network",
"appDescription": "The next generation Social Network platform.",
"appSlogan": "Social Network, Redefined.",
"faq1": "What's the relationship between Solar Network and Solian?",
"faq1a": "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.",
"faq2": "What's the relationship between Solar Network and HyperNet?",
"faq2a": "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.",
"faq3": "Which rules do I need to follow while using Solar Network?",
"faq3a": "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.",
"faq4": "If I have any question about Solar Network, where can I get help?",
"faq4a": "Feel free to email as at lily@solsynth.dev",
"ftDashboard": "Dashboard",
"ftDashboardDescription": "Get what happened recently, all in one place.",
"ftExplore": "Exploring",
"ftExploreDescription": "Content you love without the ads or algorithms.",
"ftChat": "Chat",
"ftChatDescription": "Keep in touch with your friends and communities, across the world.",
"ftNews": "News",
"ftNewsDescription": "Stay up to date with the latest news and events.",
"ftStickers": "Stickers",
"ftStickersDescription": "Express your feelings better with the various stickers.",
"ftPosting": "Posting",
"ftPostingDescription": "Share your thoughts and ideas with the world. Without limits and censorship.",
"ftPostingDescriptionAddition": "The Solar Network team will not impose any restrictions on the content you post, but according to our User Agreement, we may reduce or limit the public display of content that violates its rules.",
"whatsMore": "What's more",
"ftOpenSource": "Free, Transparent, Open-source",
"ftOpenSourceDescription": "The code powered Solar Network is open-sourced under GPLv3 license, you can check the source code down below in the download section.",
"ftSecurity": "Security",
"ftSecurityDescription": "Solar Network has done a lot in terms of security. We use multi-factor authentication to protect your account, while being safe and convenient.",
"ftNoCollecting": "No data collection",
"ftNoCollectingDescription": "Solar Network does not collect any personal information for marketing or other purposes, nor does it sell it to third parties.",
"noWaiting": "What are you waiting for?",
"noWaitingDescription": "Join Solar Network today, by downloading the app or open it in your browser and create an account."
}

View File

@ -0,0 +1,16 @@
{
"actionDownload": "下载",
"actionLearnMore": "了解更多",
"downloadPlatform": "平台",
"downloadDistribution": "分发",
"downloadAppleStore": "iOS / macOS (App Store)",
"downloadAppleTestflight": "iOS / macOS (TestFlight)",
"downloadAndroid": "安卓",
"downloadWindows": "Windows",
"downloadWeb": "网页版",
"downloadSourceCode": "源代码",
"downloadLinux": "Linux 未打包",
"downloadLinuxDebian": "deb (Debian/Ubuntu)",
"actionOpen": "打开",
"faq": "常见问题"
}

View File

@ -0,0 +1,35 @@
{
"appName": "Solar Network",
"appDescription": "下一代社交网络平台",
"appSlogan": "重新定义社交网络",
"faq1": "Solar Network 和 Solian 之间有什么关系?",
"faq1a": "Solian 是为 Solar Network 制作的官方应用程序。而 Solar Network 是由 Solsynth LLC 托管的官方 HyperNet 实例。简单来说Solian 是应用程序,而 Solar Network 是平台。",
"faq2": "Solar Network 和 HyperNet 之间有什么关系?",
"faq2a": "HyperNet 是整个项目,包括前端应用程序(公众也称 Solian和后端服务器。而 Solar Network 是由开发 HyperNet 项目的 Solsynth LLC 托管和管理的官方 HyperNet 实例。",
"faq3": "使用 Solar Network 时我需要遵守哪些规则?",
"faq3a": "查看我们的条款和条件,详细了解您在 Solar Network 上可以做什么和不能做什么。如果您违反任何这些规则,我们有权暂停或终止您的帐户。您可以在抽屉中看到它们。",
"faq4": "如果我对 Solar Network 有任何疑问,我可以在哪里获得帮助?",
"faq4a": "你可以发邮件给我们的客户服务获取支持lily@solsynth.dev",
"ftDashboard": "冲浪板",
"ftDashboardDescription": "在同一个地方,方便地了解最近发生了什么。",
"ftExplore": "探索",
"ftExploreDescription": "在没有广告或算法的干扰下欣赏你喜欢的内容。",
"ftChat": "聊天",
"ftChatDescription": "跨过地区的间隔,与你的朋友和社区进行保持联系。",
"ftNews": "新闻",
"ftNewsDescription": "不行千里,也能知晓天下事。",
"ftStickers": "贴图 / 表情",
"ftStickersDescription": "使用各种贴纸更好地表达您的感受。",
"ftPosting": "撰写",
"ftPostingDescription": "在没有限制和审查的环境,与世界分享你的想法和想法",
"ftPostingDescriptionAddition": "Solar Network 团队不会对您发表的内容做任何限制,但是根据我们的《用户协议》,我们可能会对违反其规则的内容进行减少或限制公开展示。",
"whatsMore": "还有更多",
"ftOpenSource": "自由、透明、开源",
"ftOpenSourceDescription": "驱动 Solar Network 的代码在 GPLv3 许可下开源,您可以在下面的下载区域查看源代码。",
"ftSecurity": "安全",
"ftSecurityDescription": "Solar Network 在安全性方面做了很多,我们采用多因子验证方式来保护你的帐号,同时具备安全和方便。",
"ftNoCollecting": "不采集数据",
"ftNoCollectingDescription": "Solar Network 不会收集任何个人信息用于营销或者其他目的,更不会出售给第三方。",
"noWaiting": "你还在等待什么?",
"noWaitingDescription": "通过下载 / 在浏览器中打开 Solian 并创建一个帐号,现在就加入 Solar Network 吧!"
}

15
roadsign.toml Normal file
View File

@ -0,0 +1,15 @@
id = "capital"
[[locations]]
id = "capital"
hosts = ["solsynth.dev", "www.solsynth.dev"]
paths = ["/"]
[[locations.destinations]]
id = "capital-destination"
uri = "http://localhost:3000"
[[applications]]
id = "capital-app"
workdir = "/workdir/capital"
command = ["node", "standalone/server.js"]
environment = ["HOSTNAME=0.0.0.0"]

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 KiB

View File

@ -1,6 +1,6 @@
import { useUserStore } from '@/services/user'
import { useUserStore } from 'solar-js-sdk'
import { AppBar, AppBarProps, Avatar, IconButton, Toolbar, Typography, useScrollTrigger, useTheme } from '@mui/material'
import { getAttachmentUrl } from '@/services/network'
import { getAttachmentUrl } from 'solar-js-sdk'
import MenuIcon from '@mui/icons-material/Menu'
import AccountCircle from '@mui/icons-material/AccountCircle'
import Link from 'next/link'
@ -47,7 +47,7 @@ export function CapAppBar() {
<CapDrawer width={drawerWidth} open={open} onClose={() => setOpen(false)} />
<AppBarScroll elevation={0}>
<AppBar position="sticky" elevation={0} color="transparent" className="backdrop-blur-md">
<AppBar position="sticky" elevation={0} color="transparent" className="backdrop-blur-md z-10">
<Toolbar>
<IconButton
size="large"

View File

@ -16,10 +16,11 @@ import Image from 'next/image'
import ExploreIcon from '@mui/icons-material/Explore'
import PhotoLibraryIcon from '@mui/icons-material/PhotoLibrary'
import AppsIcon from '@mui/icons-material/Apps'
import NextLink from 'next/link'
import { useRouter } from 'next/router'
interface NavLink {
export interface NavLink {
title: string
icon?: JSX.Element
href: string
@ -39,6 +40,11 @@ export function CapDrawer({ width, open, onClose }: { width: number; open: boole
icon: <PhotoLibraryIcon />,
href: '/attachments',
},
{
title: 'Matrix',
icon: <AppsIcon />,
href: '/matrix',
},
]
const additionalLinks: NavLink[] = [
@ -46,6 +52,10 @@ export function CapDrawer({ width, open, onClose }: { width: number; open: boole
title: 'Terms & Conditions',
href: '/terms',
},
{
title: 'Solar Console',
href: '/console',
},
]
return (
@ -87,7 +97,7 @@ export function CapDrawer({ width, open, onClose }: { width: number; open: boole
))}
</List>
<Divider />
<Box sx={{ display: 'flex', flexWrap: 'wrap', px: 2, py: 1.5 }}>
<Box sx={{ display: 'flex', flexWrap: 'wrap', px: 2, py: 1.5, gap: 1 }}>
{additionalLinks.map((l) => (
<NextLink passHref href={l.href} key={l.href}>
<Link variant="body2" color={'textSecondary'} fontSize={13}>

View File

@ -1,5 +1,5 @@
import { SnAttachment } from '@/services/attachment'
import { getAttachmentUrl } from '@/services/network'
import { SnAttachment } from 'solar-js-sdk'
import { getAttachmentUrl } from 'solar-js-sdk'
import { QuestionMark } from '@mui/icons-material'
import { Link, Paper, Typography } from '@mui/material'
import { ComponentProps } from 'react'

View File

@ -1,14 +1,13 @@
'use client'
import { SnAuthFactor, SnAuthResult, SnAuthTicket } from '@/services/auth'
import { sni } from '@/services/network'
import { setTokenCookies, SnAuthFactor, SnAuthResult, SnAuthTicket } from 'solar-js-sdk'
import { sni } from 'solar-js-sdk'
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
@ -44,8 +43,7 @@ export function SnLoginCheckpoint({
})
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 })
setTokenCookies(atk, rtk)
console.log('[Authenticator] User has been logged in. Result atk: ', atk)
}

View File

@ -1,13 +1,15 @@
'use client'
import { SnAuthFactor, SnAuthTicket } from '@/services/auth'
import { sni } from '@/services/network'
import { SnAuthFactor, SnAuthTicket } from 'solar-js-sdk'
import { sni } from 'solar-js-sdk'
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'
import PinIcon from '@mui/icons-material/Pin'
import NotificationsActiveIcon from '@mui/icons-material/NotificationsActive'
export function SnLoginRouter({
ticket,
@ -18,8 +20,13 @@ export function SnLoginRouter({
factorList: SnAuthFactor[]
onNext: (val: SnAuthFactor) => void
}) {
const factorTypeIcons = [<PasswordIcon key="password-icon" />, <EmailIcon key="email-icon" />]
const factorTypeLabels = ['Password', 'Email verification code']
const factorTypeIcons = [
<PasswordIcon key="password-icon" />,
<EmailIcon key="email-icon" />,
<PinIcon key="pin-icon" />,
<NotificationsActiveIcon key="notification-icon" />,
]
const factorTypeLabels = ['Password', 'Email verification code', 'Time-based OTP', 'In-app verification code']
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState<boolean>(false)

View File

@ -1,10 +1,10 @@
'use client'
import { useState } from 'react'
import { sni } from '@/services/network'
import { sni } from 'solar-js-sdk'
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 { SnAuthFactor, SnAuthResult, SnAuthTicket } from 'solar-js-sdk'
import { useForm } from 'react-hook-form'
import NextLink from 'next/link'

View File

@ -0,0 +1,77 @@
import { Noto_Serif_TC } from 'next/font/google'
import { useState, useEffect } from 'react'
export interface TimeDiff {
days: number
hours: number
minutes: number
seconds: number
isCountdown: boolean
}
const serifFont = Noto_Serif_TC({
weight: ['400', '500', '700'],
subsets: ['latin'],
display: 'swap',
})
export function CountdownTimer({ targetDate, onUpdate }: { targetDate: Date; onUpdate: (diff: TimeDiff) => void }) {
const [timeDiff, setTimeDiff] = useState({
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
isCountdown: true,
})
useEffect(() => {
const updateTimeDiff = () => {
const now = new Date()
const diff = targetDate.getTime() - now.getTime()
const absDiff = Math.abs(diff)
const isCountdown = diff > 0
const days = Math.floor(absDiff / (1000 * 60 * 60 * 24))
const hours = Math.floor((absDiff / (1000 * 60 * 60)) % 24)
const minutes = Math.floor((absDiff / (1000 * 60)) % 60)
const seconds = Math.floor((absDiff / 1000) % 60)
setTimeDiff({ days, hours, minutes, seconds, isCountdown })
onUpdate({ days, hours, minutes, seconds, isCountdown })
}
const intervalId = setInterval(updateTimeDiff, 1000)
return () => clearInterval(intervalId)
}, [])
return (
<div className="flex gap-5">
<div>
<span className="countdown font-mono text-4xl">
<span style={{ '--value': timeDiff.days } as any}></span>
</span>
<span className={serifFont.className}></span>
</div>
<div>
<span className="countdown font-mono text-4xl">
<span style={{ '--value': timeDiff.hours } as any}></span>
</span>
<span className={serifFont.className}></span>
</div>
<div>
<span className="countdown font-mono text-4xl">
<span style={{ '--value': timeDiff.minutes } as any}></span>
</span>
<span className={serifFont.className}></span>
</div>
<div>
<span className="countdown font-mono text-4xl">
<span style={{ '--value': timeDiff.seconds } as any}></span>
</span>
<span className={serifFont.className}></span>
</div>
</div>
)
}

View File

@ -0,0 +1,63 @@
import { checkAuthenticatedClient, redirectToLogin } from 'solar-js-sdk'
import { JSX, useEffect } from 'react'
import { DashboardLayout, Navigation } from '@toolpad/core'
import { Box, Stack, Typography } from '@mui/material'
import NextLink from 'next/link'
import HomeIcon from '@mui/icons-material/Home'
import AppsIcon from '@mui/icons-material/Apps'
export function ConsoleLayout({ children }: { children: JSX.Element }) {
useEffect(() => {
if (!checkAuthenticatedClient()) redirectToLogin()
}, [])
const navigation: Navigation = [
{
segment: '',
title: 'Home',
icon: <HomeIcon />,
},
{
segment: 'console/matrix',
title: 'Matrix',
icon: <AppsIcon />,
},
]
return (
<DashboardLayout
navigation={navigation}
branding={{
homeUrl: '/console',
}}
slots={{
appTitle(_) {
return (
<Stack direction="row" alignItems="center" spacing={2}>
<NextLink passHref href="/console">
<Typography variant="h6">Solar Network Console</Typography>
</NextLink>
</Stack>
)
},
toolbarActions(_) {
return <Box />
},
}}
sidebarExpandedWidth={300}
defaultSidebarCollapsed
>
{children}
</DashboardLayout>
)
}
export function getConsoleStaticProps(original: any) {
if (original.props.title) {
original.props.title = 'Solar Console | ' + original.props.title
}
original.props.showAppBar = false
return original
}

View File

@ -0,0 +1,123 @@
import { Collapse, Alert, TextField, Button, Box } from '@mui/material'
import { useRouter } from 'next-nprogress-bar'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { MaProduct } from 'solar-js-sdk'
import ErrorIcon from '@mui/icons-material/Error'
export interface MatrixProductForm {
name: string
alias: string
description: string
introduction: string
icon: string
previews: string[]
tags: string[]
}
export default function MaProductForm({
onSubmit,
onSuccess,
defaultValue,
}: {
onSubmit: (data: MatrixProductForm) => Promise<any>
onSuccess?: () => void
defaultValue?: MaProduct
}) {
const { handleSubmit, register } = useForm<MatrixProductForm>({
defaultValues: {
name: defaultValue?.name ?? '',
alias: defaultValue?.alias ?? '',
description: defaultValue?.description ?? '',
introduction: defaultValue?.meta?.introduction ?? '',
icon: defaultValue?.icon ?? '',
},
})
const router = useRouter()
const [previews, setPreviews] = useState<string[]>([])
const [tags, setTags] = useState<string[]>([])
useEffect(() => {
if (defaultValue?.previews) {
setPreviews(defaultValue.previews)
}
if (defaultValue?.tags) {
setTags(defaultValue.tags)
}
}, [])
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState<boolean>(false)
function callback() {
if (onSuccess) {
onSuccess()
} else {
router.push('/console/matrix')
}
}
async function submit(data: MatrixProductForm) {
try {
setBusy(true)
await onSubmit({
...data,
previews,
tags,
})
callback()
} catch (err: any) {
setError(err.toString())
} finally {
setBusy(false)
}
}
return (
<form onSubmit={handleSubmit(submit)}>
<Box display="flex" flexDirection="column" maxWidth="sm" gap={2.5}>
<Collapse in={!!error} sx={{ width: '100%' }}>
<Alert icon={<ErrorIcon fontSize="inherit" />} severity="error">
{error}
</Alert>
</Collapse>
<TextField label="Icon" placeholder="Image URL or Attachment RID" {...register('icon')} />
<TextField
label="Previews"
placeholder="Comma separated, Image URL or Attachment RID, the first one will be used as the banner"
value={previews.join(',')}
onChange={(val) => setPreviews(val.target.value.split(',').map((v) => v.trim()))}
/>
<TextField label="Name" {...register('name')} />
<TextField label="Alias" {...register('alias')} />
<TextField
label="Tags"
placeholder="Comma separated"
value={tags.join(',')}
onChange={(val) => setTags(val.target.value.split(',').map((v) => v.trim()))}
/>
<TextField minRows={3} maxRows={3} multiline label="Description" {...register('description')} />
<TextField minRows={5} multiline label="Introduction" {...register('introduction')} />
<Box sx={{ mt: 5 }} display="flex" gap={2}>
<Button variant="contained" type="submit" disabled={busy}>
Submit
</Button>
<Button onClick={callback} variant="outlined" disabled={busy}>
Cancel
</Button>
</Box>
</Box>
</form>
)
}

View File

@ -0,0 +1,428 @@
import {
Collapse,
Alert,
TextField,
Button,
Box,
FormControl,
InputLabel,
MenuItem,
Select,
Typography,
Grid2 as Grid,
IconButton,
Card,
} from '@mui/material'
import { useRouter } from 'next-nprogress-bar'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import {
MaProduct,
MaRelease,
MaReleaseAsset,
MaReleaseInstaller,
MaReleaseInstallerPatch,
MaReleaseRunner,
} from 'solar-js-sdk'
import MonacoEditor from '@monaco-editor/react'
import ErrorIcon from '@mui/icons-material/Error'
import CloseIcon from '@mui/icons-material/Close'
export interface MatrixReleaseForm {
version: string
type: number
channel: string
title: string
description: string
content: string
assets: Record<string, MaReleaseAsset>
installers: Record<string, MaReleaseInstaller>
runners: Record<string, MaReleaseRunner>
attachments: string[]
}
export default function MaReleaseForm({
onSubmit,
onSuccess,
parent,
defaultValue,
}: {
onSubmit: (data: MatrixReleaseForm) => Promise<any>
onSuccess?: () => void
parent: Partial<MaProduct>
defaultValue?: MaRelease
}) {
const { handleSubmit, register } = useForm<MatrixReleaseForm>({
defaultValues: {
title: defaultValue?.meta.title,
version: defaultValue?.version,
type: defaultValue?.type ?? 0,
channel: defaultValue?.channel,
description: defaultValue?.meta.description,
content: defaultValue?.meta.content,
attachments: defaultValue?.meta.attachments,
},
})
useEffect(() => {
if (defaultValue?.assets) {
setAssets(Object.keys(defaultValue.assets).map((k) => ({ k, v: defaultValue.assets[k] })))
}
if (defaultValue?.installers) {
setInstallers(Object.keys(defaultValue.installers).map((k) => ({ k, v: defaultValue.installers[k] })))
}
if (defaultValue?.runners) {
setRunners(Object.keys(defaultValue.runners).map((k) => ({ k, v: defaultValue.runners[k] })))
}
}, [])
const router = useRouter()
const [assets, setAssets] = useState<{ k: string; v: MaReleaseAsset }[]>([])
const [installers, setInstallers] = useState<{ k: string; v: MaReleaseInstaller }[]>([])
const [runners, setRunners] = useState<{ k: string; v: MaReleaseRunner }[]>([])
function addAsset() {
setAssets((val) => [...val, { k: '', v: { uri: '', contentType: '' } }])
}
function addInstaller() {
setInstallers((val) => [...val, { k: '', v: { workdir: '', script: '', patches: [] } }])
}
function addRunner() {
setRunners((val) => [...val, { k: '', v: { workdir: '', script: '', label: '' } }])
}
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState<boolean>(false)
function callback() {
if (onSuccess) {
onSuccess()
} else {
router.push(`/console/matrix/products/${parent?.id}`)
}
}
async function submit(data: MatrixReleaseForm) {
try {
setBusy(true)
await onSubmit({
...data,
assets: assets.reduce((a, { k, v }) => ({ ...a, [k]: v }), {}),
installers: installers.reduce((a, { k, v }) => ({ ...a, [k]: v }), {}),
runners: runners.reduce((a, { k, v }) => ({ ...a, [k]: v }), {}),
})
callback()
} catch (err: any) {
setError(err.toString())
} finally {
setBusy(false)
}
}
return (
<form onSubmit={handleSubmit(submit)}>
<Box display="flex" flexDirection="column" maxWidth="sm" gap={2.5}>
<Collapse in={!!error} sx={{ width: '100%' }}>
<Alert icon={<ErrorIcon fontSize="inherit" />} severity="error">
{error}
</Alert>
</Collapse>
<TextField label="Version" placeholder="Major.Minor.Patch" {...register('version', { required: true })} />
<FormControl fullWidth>
<InputLabel id="release-type">Type</InputLabel>
<Select
labelId="release-type"
label="Type"
defaultValue={defaultValue?.type}
{...register('type', { required: true })}
>
<MenuItem value={0}>Full Release</MenuItem>
<MenuItem value={1}>Patch Release</MenuItem>
</Select>
</FormControl>
<TextField label="Title" {...register('title')} />
<TextField label="Alias" {...register('channel')} />
<TextField minRows={3} maxRows={3} multiline label="Description" {...register('description')} />
<TextField minRows={5} multiline label="Content" {...register('content')} />
<Box sx={{ mt: 3, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="h5">Assets</Typography>
{assets.map(({ k, v }, idx) => (
<Card variant="outlined" key={idx}>
<Box sx={{ pl: 2, pr: 4, py: 2 }}>
<Grid container spacing={2}>
<Grid size={11}>
<TextField
label="Platform"
sx={{ width: '100%' }}
value={k}
onChange={(val) => {
setAssets((data) =>
data.map((ele, index) => (index == idx ? { k: val.target.value, v: ele.v } : ele)),
)
}}
/>
</Grid>
<Grid size={1} sx={{ display: 'grid', placeItems: 'center' }}>
<IconButton
onClick={() => {
setAssets((data) => data.filter((_, index) => index != idx))
}}
>
<CloseIcon />
</IconButton>
</Grid>
<Grid size={8}>
<TextField
label="URI"
sx={{ width: '100%' }}
value={v.uri}
onChange={(val) => {
setAssets((data) =>
data.map((ele, index) =>
index == idx ? { v: { ...ele.v, uri: val.target.value }, k: ele.k } : ele,
),
)
}}
/>
</Grid>
<Grid size={4}>
<TextField
label="Content Type"
sx={{ width: '100%' }}
value={v.contentType}
onChange={(val) => {
setAssets((data) =>
data.map((ele, index) =>
index == idx ? { v: { ...ele.v, contentType: val.target.value }, k: ele.k } : ele,
),
)
}}
/>
</Grid>
</Grid>
</Box>
</Card>
))}
<Box>
<Button variant="outlined" onClick={addAsset}>
Add
</Button>
</Box>
</Box>
<Box sx={{ mt: 3, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="h5">Installers</Typography>
{installers.map(({ k, v }, idx) => (
<Card variant="outlined" key={idx}>
<Box sx={{ pl: 2, pr: 4, py: 2 }}>
<Grid container spacing={2}>
<Grid size={4}>
<TextField
label="Platform"
sx={{ width: '100%' }}
value={k}
onChange={(val) => {
setInstallers((data) =>
data.map((ele, index) => (index == idx ? { k: val.target.value, v: ele.v } : ele)),
)
}}
/>
</Grid>
<Grid size={7}>
<TextField
label="Working Directory"
sx={{ width: '100%' }}
value={v.workdir}
onChange={(val) => {
setInstallers((data) =>
data.map((ele, index) =>
index == idx ? { k: ele.k, v: { ...ele.v, workdir: val.target.value } } : ele,
),
)
}}
/>
</Grid>
<Grid size={1} sx={{ display: 'grid', placeItems: 'center' }}>
<IconButton
onClick={() => {
setInstallers((data) => data.filter((_, index) => index != idx))
}}
>
<CloseIcon />
</IconButton>
</Grid>
<Grid size={12}>
<Typography variant="subtitle1" sx={{ mx: 1 }}>
Script
</Typography>
<Card variant="outlined">
<MonacoEditor
height="140px"
width="100%"
options={{ minimap: { enabled: false } }}
defaultValue={v.script}
onChange={(val) =>
setInstallers((data) =>
data.map((ele, index) => (index == idx ? { v: { ...ele.v, script: val }, k: ele.k } : ele)),
)
}
/>
</Card>
</Grid>
<Grid size={12}>
<Typography variant="subtitle1" sx={{ mx: 1 }}>
Patches
</Typography>
<Card variant="outlined">
<MonacoEditor
height="140px"
width="100%"
options={{ minimap: { enabled: false } }}
defaultValue={v.patches.map((p) => `${p.action}:${p.glob}`).join('\n')}
onChange={(val) =>
setInstallers((data) =>
data.map((ele, index) =>
index == idx
? {
v: {
...ele.v,
patches: val?.split('\n')?.map((p) => ({
action: p.split(':')[0],
glob: p.split(':')[1],
})) as MaReleaseInstallerPatch[],
},
k: ele.k,
}
: ele,
),
)
}
/>
</Card>
</Grid>
</Grid>
</Box>
</Card>
))}
<Box>
<Button variant="outlined" onClick={addInstaller}>
Add
</Button>
</Box>
</Box>
<Box sx={{ mt: 3, display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="h5">Runners</Typography>
{runners.map(({ k, v }, idx) => (
<Card variant="outlined" key={idx}>
<Box sx={{ pl: 2, pr: 4, py: 2 }}>
<Grid container spacing={2}>
<Grid size={4}>
<TextField
label="Platform"
sx={{ width: '100%' }}
value={k}
onChange={(val) => {
setRunners((data) =>
data.map((ele, index) => (index == idx ? { k: val.target.value, v: ele.v } : ele)),
)
}}
/>
</Grid>
<Grid size={7}>
<TextField
label="Working Directory"
sx={{ width: '100%' }}
value={v.workdir}
onChange={(val) => {
setRunners((data) =>
data.map((ele, index) =>
index == idx ? { k: ele.k, v: { ...ele.v, workdir: val.target.value } } : ele,
),
)
}}
/>
</Grid>
<Grid size={1} sx={{ display: 'grid', placeItems: 'center' }}>
<IconButton
onClick={() => {
setRunners((data) => data.filter((_, index) => index != idx))
}}
>
<CloseIcon />
</IconButton>
</Grid>
<Grid size={12}>
<TextField
label="Label"
sx={{ width: '100%' }}
value={v.label}
onChange={(val) => {
setRunners((data) =>
data.map((ele, index) =>
index == idx ? { k: ele.k, v: { ...ele.v, label: val.target.value } } : ele,
),
)
}}
/>
</Grid>
<Grid size={12}>
<Typography variant="subtitle1" sx={{ mx: 1 }}>
Script
</Typography>
<Card variant="outlined">
<MonacoEditor
height="280px"
width="100%"
options={{ minimap: { enabled: false } }}
defaultValue={v.script}
onChange={(val) =>
setRunners((data) =>
data.map((ele, index) =>
index == idx ? { v: { ...ele.v, script: val ?? '' }, k: ele.k } : ele,
),
)
}
/>
</Card>
</Grid>
</Grid>
</Box>
</Card>
))}
<Box>
<Button variant="outlined" onClick={addRunner}>
Add
</Button>
</Box>
</Box>
<Box sx={{ mt: 5 }} display="flex" gap={2}>
<Button variant="contained" type="submit" disabled={busy}>
Submit
</Button>
<Button onClick={callback} variant="outlined" disabled={busy}>
Cancel
</Button>
</Box>
</Box>
</form>
)
}

View File

@ -1,11 +1,14 @@
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 { PagesProgressBar as ProgressBar } from 'next-nprogress-bar'
import { useUserStore } from '@/services/user'
import { AppProvider } from '@toolpad/core/nextjs'
import { useUserStore } from 'solar-js-sdk'
import { useEffect } from 'react'
import { appWithTranslation } from 'next-i18next'
import Head from 'next/head'
const fontRoboto = Roboto({
@ -30,14 +33,18 @@ const siteTheme = createTheme({
},
})
export default function App({ Component, pageProps }: AppProps) {
function App({ Component, pageProps }: AppProps) {
const userStore = useUserStore()
useEffect(() => {
userStore.fetchUser()
}, [])
const title = pageProps.title ? `${pageProps.title} | Solsynth LLC` : 'Solsynth LLC'
const title = pageProps.title
? pageProps.title.startsWith('Solar Console')
? pageProps.title
: `${pageProps.title} | Solsynth LLC`
: 'Solsynth LLC'
return (
<>
@ -56,20 +63,24 @@ export default function App({ Component, pageProps }: AppProps) {
<link rel="apple-touch-icon" href="/apple-icon.png" type="image/png" />
</Head>
<ThemeProvider theme={siteTheme}>
<CssBaseline />
<ProgressBar
height="4px"
color={siteTheme.palette.primary.main}
options={{ showSpinner: false }}
shallowRouting
/>
<AppProvider>
<ThemeProvider theme={siteTheme}>
<CssBaseline />
<ProgressBar
height="4px"
color={siteTheme.palette.primary.main}
options={{ showSpinner: false }}
shallowRouting
/>
<CapAppBar />
<Box sx={{ minHeight: 'calc(100vh - 64px)' }}>
<Component {...pageProps} />
</Box>
</ThemeProvider>
{(pageProps.showAppBar ?? true) && <CapAppBar />}
<Box sx={{ minHeight: 'calc(100vh - 64px)' }}>
<Component {...pageProps} />
</Box>
</ThemeProvider>
</AppProvider>
</>
)
}
export default appWithTranslation(App)

View File

@ -4,7 +4,6 @@ import {
DocumentHeadTagsProps,
documentGetInitialProps,
} from '@mui/material-nextjs/v15-pagesRouter'
import { SpeedInsights } from '@vercel/speed-insights/next'
import { Html, Head, Main, NextScript, DocumentContext, DocumentProps } from 'next/document'
export default function Document(props: DocumentProps & DocumentHeadTagsProps) {
@ -12,12 +11,16 @@ export default function Document(props: DocumentProps & DocumentHeadTagsProps) {
<AppCacheProvider {...props}>
<Html lang="en">
<Head>
<script
defer
src="https://cloud.umami.is/script.js"
data-website-id="eef151fb-07e2-461b-8b7f-2547aab735d4"
></script>
<DocumentHeadTags {...props} />
</Head>
<body className="antialiased">
<Main />
<NextScript />
<SpeedInsights />
</body>
</Html>
</AppCacheProvider>

View File

@ -1,6 +1,6 @@
import { AttachmentItem } from '@/components/attachments/AttachmentItem'
import { SnAttachment } from '@/services/attachment'
import { sni } from '@/services/network'
import { SnAttachment } from 'solar-js-sdk'
import { sni } from 'solar-js-sdk'
import { Box, ImageList, ImageListItem, Pagination, useMediaQuery, useTheme } from '@mui/material'
import { GetServerSideProps, InferGetServerSidePropsType } from 'next'
import { useRouter } from 'next/router'

View File

@ -0,0 +1,190 @@
import {
styled,
Button,
Container,
Card,
Box,
List,
ListItem,
ListItemText,
IconButton,
FormControl,
InputLabel,
MenuItem,
Select,
LinearProgress,
Typography,
Alert,
Collapse,
} from '@mui/material'
import { MultipartProgress, SnAttachment, UploadAttachmentTask } from 'solar-js-sdk'
import { useState } from 'react'
import ErrorIcon from '@mui/icons-material/Error'
import CloseIcon from '@mui/icons-material/Close'
import CloudUploadIcon from '@mui/icons-material/CloudUpload'
import PlayIcon from '@mui/icons-material/PlayArrow'
const VisuallyHiddenInput = styled('input')({
clip: 'rect(0 0 0 0)',
clipPath: 'inset(50%)',
height: 1,
overflow: 'hidden',
position: 'absolute',
bottom: 0,
left: 0,
whiteSpace: 'nowrap',
width: 1,
})
interface FileUploadTask {
file: File
attachment?: SnAttachment
}
export default function AttachmentNew() {
const [files, setFiles] = useState<FileUploadTask[]>([])
const [busy, setBusy] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const [task, setTask] = useState<FileUploadTask>()
const [taskProgress, setTaskProgress] = useState<MultipartProgress>()
const [pool, setPool] = useState<string>('interactive')
async function upload() {
if (files.length == 0) return
setBusy(true)
for (let idx = 0; idx < files.length; idx++) {
if (files[idx].attachment) continue
try {
const task = new UploadAttachmentTask(files[idx].file, pool)
setTask(files[idx])
task.onProgress = (progress) => setTaskProgress(progress)
task.onError = (err) => setError(err)
const attachment = await task.submit()
setFiles((files) => files.map((f, i) => (i == idx ? { ...f, attachment } : f)))
} catch (err: any) {
setError(err.toString())
setBusy(false)
return
}
}
setBusy(false)
}
return (
<Container
sx={{
height: 'calc(100vh - 64px)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
}}
maxWidth="xs"
>
<Box sx={{ width: '100%', mx: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
component="label"
role={undefined}
variant={files.length == 0 ? 'contained' : 'text'}
tabIndex={-1}
startIcon={<CloudUploadIcon />}
>
Pick files
<VisuallyHiddenInput
type="file"
onChange={(event) =>
setFiles(
Array.from(event.target.files ?? []).map((f) => ({
file: f,
})),
)
}
multiple
/>
</Button>
{files.length > 0 && (
<Button
color="success"
variant="contained"
startIcon={<PlayIcon />}
disabled={busy}
onClick={() => upload()}
>
Upload
</Button>
)}
</Box>
<Collapse in={!!error} sx={{ width: '100%' }}>
<Alert sx={{ mb: 5 }} icon={<ErrorIcon fontSize="inherit" />} severity="error">
{error}
</Alert>
</Collapse>
{taskProgress && (
<Box sx={{ mt: 5, width: '100%', textAlign: 'center' }}>
<Typography variant="body2" gutterBottom>
{task?.file.name ?? 'Waiting...'}
</Typography>
<LinearProgress
value={taskProgress?.value ? taskProgress.value * 100 : 0}
sx={{ borderRadius: 4 }}
variant="determinate"
/>
</Box>
)}
{files.length > 0 && (
<Box sx={{ mt: 5, width: '100%' }}>
<Card variant="outlined">
<List>
{files?.map((f, idx) => (
<ListItem
dense
key={idx}
secondaryAction={
<IconButton
edge="end"
aria-label="delete"
disabled={busy}
sx={{ marginRight: 1 }}
onClick={() => setFiles((files) => files.filter((_, i) => i != idx))}
>
<CloseIcon />
</IconButton>
}
>
<ListItemText primary={f.file.name} secondary={f.attachment?.rid} />
</ListItem>
))}
</List>
</Card>
</Box>
)}
<FormControl fullWidth sx={{ mt: 5 }}>
<InputLabel id="attachment-pool">Attachment Pool</InputLabel>
<Select
labelId="attachment-pool"
value={pool}
disabled={busy}
label="Attachment Pool"
onChange={(evt) => setPool(evt.target.value)}
>
<MenuItem value={'interactive'}>Interactive</MenuItem>
<MenuItem value={'messaging'}>Messaging</MenuItem>
</Select>
</FormControl>
</Box>
</Container>
)
}

View File

@ -1,8 +1,8 @@
import { sni } from '@/services/network'
import { sni } from 'solar-js-sdk'
import { Container, Box, Typography, Alert, Collapse, Button, CircularProgress, Card, CardContent } from '@mui/material'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
import { checkAuthenticatedClient, redirectToLogin, SnAuthTicket } from '@/services/auth'
import { checkAuthenticatedClient, redirectToLogin, SnAuthTicket } from 'solar-js-sdk'
import ErrorIcon from '@mui/icons-material/Error'
import CloseIcon from '@mui/icons-material/Close'

View File

@ -1,11 +1,12 @@
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 { SnAuthFactor, SnAuthTicket } from 'solar-js-sdk'
import { useUserStore } from 'solar-js-sdk'
import { Box, Container, Typography } from '@mui/material'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { useSearchParams } from 'next/navigation'
export default function Login() {
const [period, setPeriod] = useState<number>(0)
@ -14,17 +15,12 @@ export default function Login() {
const [factor, setFactor] = useState<SnAuthFactor | null>(null)
const router = useRouter()
const searchParams = useSearchParams()
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()
}
const redirectUrl = searchParams.get('redirect_uri')
if (redirectUrl) {
if (redirectUrl.startsWith('/')) {
router.push(redirectUrl)
} else {

View File

@ -0,0 +1,51 @@
import { ConsoleLayout, getConsoleStaticProps } from '@/components/layouts/ConsoleLayout'
import { Typography, Container, Box, Grid2 as Grid, Card, CardContent, CardActionArea } from '@mui/material'
import NextLink from 'next/link'
import DynamicFormIcon from '@mui/icons-material/DynamicForm'
import AppsIcon from '@mui/icons-material/Apps'
export function getStaticProps() {
return getConsoleStaticProps({
props: {
title: 'Welcome',
},
})
}
export default function ConsoleLanding() {
return (
<ConsoleLayout>
<Container sx={{ py: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
<Box>
<DynamicFormIcon sx={{ fontSize: 64, mb: 2 }} />
<Typography variant="subtitle2">Welcome to the</Typography>
<Typography variant="h3" component="h1">
Console
</Typography>
<Typography variant="subtitle1">of the Solar Network</Typography>
</Box>
<Grid container columns={{ xs: 1, sm: 2, md: 3 }} spacing={4}>
<Grid size={1}>
<NextLink passHref href="/console/matrix">
<CardActionArea>
<Card sx={{ width: '100%' }}>
<CardContent>
<AppsIcon sx={{ fontSize: 32, mb: 1.5 }} />
<Typography variant="h5" gutterBottom>
Matrix
</Typography>
<Typography variant="body1">
Publish and versioning your application with Matrix Marketplace.
</Typography>
</CardContent>
</Card>
</CardActionArea>
</NextLink>
</Grid>
</Grid>
</Container>
</ConsoleLayout>
)
}

View File

@ -0,0 +1,88 @@
import { ConsoleLayout, getConsoleStaticProps } from '@/components/layouts/ConsoleLayout'
import { MaProduct } from 'solar-js-sdk'
import { sni } from 'solar-js-sdk'
import { Typography, Container, Box, Button, Grid2 as Grid, Card, CardContent, CardActions } from '@mui/material'
import NextLink from 'next/link'
import { useEffect, useState } from 'react'
export async function getStaticProps() {
return getConsoleStaticProps({
props: {
title: 'Matrix Marketplace',
},
})
}
export default function MatrixMarketplace() {
const [products, setProducts] = useState<MaProduct[]>([])
async function fetchProducts() {
const { data: resp } = await sni.get<{ data: MaProduct[] }>('/cgi/ma/products/created', {
params: {
take: 10,
},
})
setProducts(resp.data)
}
useEffect(() => {
fetchProducts()
}, [])
async function deleteProduct(id: number) {
const yes = confirm(`Are you sure you want to delete this product #${id}?`)
if (!yes) return
await sni.delete('/cgi/ma/products/' + id)
await fetchProducts()
}
return (
<ConsoleLayout>
<Container sx={{ py: 16, display: 'flex', flexDirection: 'column', gap: 4 }}>
<Box>
<Typography variant="h3" component="h1">
Matrix Marketplace
</Typography>
<Typography variant="body1">
The new way to release your app, implement version check and auto updating.
</Typography>
</Box>
<Box display="flex" flexDirection="column" gap={2}>
<Box>
<NextLink passHref href="/console/matrix/products/new">
<Button variant="contained">Create a product</Button>
</NextLink>
</Box>
<Grid container columns={{ xs: 1, sm: 2, md: 3 }} spacing={4}>
{products.map((p) => (
<Grid size={1} key={p.id}>
<Card sx={{ width: '100%' }}>
<CardContent>
<Typography variant="h5" gutterBottom>
{p.name}
</Typography>
<Typography variant="body1">{p.description}</Typography>
</CardContent>
<CardActions>
<NextLink passHref href={`/console/matrix/products/${p.id}`}>
<Button size="small">Details</Button>
</NextLink>
<NextLink passHref href={`/console/matrix/products/${p.id}/edit`}>
<Button size="small">Edit</Button>
</NextLink>
<Button size="small" color="error" onClick={() => deleteProduct(p.id)}>
Delete
</Button>
</CardActions>
</Card>
</Grid>
))}
</Grid>
</Box>
</Container>
</ConsoleLayout>
)
}

View File

@ -0,0 +1,39 @@
import { ConsoleLayout, getConsoleStaticProps } from '@/components/layouts/ConsoleLayout'
import { Typography, Container, Box } from '@mui/material'
import { MaProduct, sni } from 'solar-js-sdk'
import { GetServerSideProps, InferGetServerSidePropsType } from 'next'
import MaProductForm, { MatrixProductForm } from '@/components/matrix/MaProductForm'
export const getServerSideProps: GetServerSideProps = (async (context) => {
const id = context.params!.id
const { data } = await sni.get<MaProduct>('/cgi/ma/products/' + id)
return getConsoleStaticProps({
props: {
title: `Edit Product "${data.name}"`,
product: data,
},
})
}) satisfies GetServerSideProps<{ product: MaProduct }>
export default function ProductEdit({ product }: InferGetServerSidePropsType<typeof getServerSideProps>) {
async function onSubmit(data: MatrixProductForm) {
await sni.put('/cgi/ma/products/' + product.id, data)
}
return (
<ConsoleLayout>
<Container sx={{ py: 16, display: 'flex', flexDirection: 'column', gap: 4 }}>
<Box>
<Typography variant="h3" component="h1">
Edit product
</Typography>
<Typography variant="subtitle1">{product.name}</Typography>
</Box>
<MaProductForm onSubmit={onSubmit} defaultValue={product} />
</Container>
</ConsoleLayout>
)
}

View File

@ -0,0 +1,107 @@
import { ConsoleLayout, getConsoleStaticProps } from '@/components/layouts/ConsoleLayout'
import { Box, Button, Container, Typography, Grid2 as Grid, Card, CardContent, CardActions } from '@mui/material'
import { GetServerSideProps, InferGetServerSidePropsType } from 'next'
import { sni, MaProduct, MaRelease, getAttachmentUrl } from 'solar-js-sdk'
import { useEffect, useState } from 'react'
import NextLink from 'next/link'
export const getServerSideProps: GetServerSideProps = (async (context) => {
const id = context.params!.id
const { data } = await sni.get<MaProduct>('/cgi/ma/products/' + id)
return getConsoleStaticProps({
props: {
title: `Product "${data.name}"`,
product: data,
},
})
}) satisfies GetServerSideProps<{ product: MaProduct }>
export default function ProductDetails({ product }: InferGetServerSidePropsType<typeof getServerSideProps>) {
const [releases, setReleases] = useState<MaRelease[]>([])
async function fetchReleases() {
const { data: resp } = await sni.get<{ data: MaRelease[] }>('/cgi/ma/products/' + product.id + '/releases', {
params: {
take: 10,
},
})
setReleases(resp.data)
}
useEffect(() => {
fetchReleases()
}, [])
async function deleteRelease(id: number) {
const yes = confirm(`Are you sure you want to delete this release #${id}?`)
if (!yes) return
await sni.delete('/cgi/ma/products/' + product.id + '/releases/' + id)
await fetchReleases()
}
return (
<ConsoleLayout>
<>
{product.previews && (
<img
src={getAttachmentUrl(product.previews[0])}
alt={product.name}
style={{ objectFit: 'cover', aspectRatio: 16 / 5 }}
className='border-b border-1'
/>
)}
<Container sx={{ pt: (product.previews ? 8 : 16), pb: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
<Box maxWidth="sm">
<Typography variant="h3" component="h1">
{product.name}
</Typography>
<Typography variant="subtitle1">{product.description}</Typography>
</Box>
<Box display="flex" flexDirection="column" gap={2}>
<Typography variant="h4" component="h2">
Releases
</Typography>
<NextLink passHref href={`/console/matrix/products/${product.id}/releases/new`}>
<Button variant="contained">Create a release</Button>
</NextLink>
<Grid container columns={{ xs: 1, sm: 2, md: 3 }} spacing={2}>
{releases.map((r: any) => (
<Grid size={1} key={r.id}>
<Card>
<CardContent>
<Typography variant="caption">{r.version}</Typography>
<Typography variant="h5" component="h2">
{r.meta.title}
</Typography>
<Typography variant="body1" gutterBottom>
{r.type == 0 ? 'Full Release' : 'Patch Release'}
</Typography>
<Typography variant="body1">{r.meta.description}</Typography>
</CardContent>
<CardActions>
<NextLink passHref href={`/console/matrix/products/${r.productId}/releases/${r.id}/edit`}>
<Button size="small">Edit</Button>
</NextLink>
<Button size="small" color="error" onClick={() => deleteRelease(r.id)}>
Delete
</Button>
</CardActions>
</Card>
</Grid>
))}
</Grid>
</Box>
</Container>
</>
</ConsoleLayout>
)
}

View File

@ -0,0 +1,40 @@
import { ConsoleLayout, getConsoleStaticProps } from '@/components/layouts/ConsoleLayout'
import { Typography, Container, Box } from '@mui/material'
import { sni } from 'solar-js-sdk'
import { GetServerSideProps, InferGetServerSidePropsType } from 'next'
import MaReleaseForm, { MatrixReleaseForm } from '@/components/matrix/MaReleaseForm'
export const getServerSideProps: GetServerSideProps = (async (context) => {
const id = context.params!.id
const releaseId = context.params!.release
const { data } = await sni.get<any>('/cgi/ma/products/' + id + '/releases/' + releaseId)
return getConsoleStaticProps({
props: {
title: `Edit Release v${data.version}`,
release: data,
},
})
}) satisfies GetServerSideProps<{ release: any }>
export default function ReleaseEdit({ release }: InferGetServerSidePropsType<typeof getServerSideProps>) {
async function onSubmit(data: MatrixReleaseForm) {
await sni.put('/cgi/ma/products/' + release.productId + '/releases/' + release.id, data)
}
return (
<ConsoleLayout>
<Container sx={{ py: 16, display: 'flex', flexDirection: 'column', gap: 4 }}>
<Box>
<Typography variant="h3" component="h1">
Edit releases
</Typography>
<Typography variant="subtitle1">{release.meta.title}</Typography>
</Box>
<MaReleaseForm onSubmit={onSubmit} defaultValue={release} parent={{ id: release.productId }} />
</Container>
</ConsoleLayout>
)
}

View File

@ -0,0 +1,40 @@
import { ConsoleLayout, getConsoleStaticProps } from '@/components/layouts/ConsoleLayout'
import { Typography, Container, Box } from '@mui/material'
import { MaProduct, sni } from 'solar-js-sdk'
import MaReleaseForm, { MatrixReleaseForm } from '@/components/matrix/MaReleaseForm'
import { GetServerSideProps, InferGetServerSidePropsType } from 'next'
export const getServerSideProps: GetServerSideProps = (async (context) => {
const id = context.params!.id
const { data } = await sni.get<MaProduct>('/cgi/ma/products/' + id)
return getConsoleStaticProps({
props: {
title: `New Release for "${data.name}"`,
product: data,
},
})
}) satisfies GetServerSideProps<{ product: MaProduct }>
export default function ReleaseNew({ product }: InferGetServerSidePropsType<typeof getServerSideProps>) {
async function onSubmit(data: MatrixReleaseForm) {
await sni.post(`/cgi/ma/products/${product.id}/releases`, data)
}
return (
<ConsoleLayout>
<Container sx={{ py: 16, display: 'flex', flexDirection: 'column', gap: 4 }}>
<Box>
<Typography variant="h3" component="h1">
Create a release
</Typography>
<Typography variant="subtitle1">for {product.name}</Typography>
</Box>
<MaReleaseForm onSubmit={onSubmit} parent={product} />
</Container>
</ConsoleLayout>
)
}

View File

@ -0,0 +1,31 @@
import { ConsoleLayout, getConsoleStaticProps } from '@/components/layouts/ConsoleLayout'
import { Typography, Container } from '@mui/material'
import { sni } from 'solar-js-sdk'
import MaProductForm, { MatrixProductForm } from '@/components/matrix/MaProductForm'
export async function getStaticProps() {
return getConsoleStaticProps({
props: {
title: 'New Product',
},
})
}
export default function ProductNew() {
async function onSubmit(data: MatrixProductForm) {
await sni.post('/cgi/ma/products', data)
}
return (
<ConsoleLayout>
<Container sx={{ py: 16, display: 'flex', flexDirection: 'column', gap: 4 }}>
<Typography variant="h3" component="h1">
Create a product
</Typography>
<MaProductForm onSubmit={onSubmit} />
</Container>
</ConsoleLayout>
)
}

View File

@ -0,0 +1,34 @@
import { CountdownTimer } from '@/components/events/CountdownTimer'
import { Box, Container, Typography } from '@mui/material'
import { Noto_Serif_TC } from 'next/font/google'
import { useState } from 'react'
const serifFont = Noto_Serif_TC({
weight: ['400', '500', '700'],
subsets: ['latin'],
display: 'swap',
})
export default function LunarCountdownFor2025() {
const [isCountdown, setIsCountdown] = useState(true)
return (
<Container sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 'calc(100vh - 64px)' }}>
<Box>
<Typography style={serifFont.style} sx={{ textAlign: 'center' }}>
</Typography>
<Typography variant="h5" style={serifFont.style} sx={{ textAlign: 'center', fontWeight: 'bold' }}>
</Typography>
<Typography style={serifFont.style} sx={{ textAlign: 'center', mb: 3 }}>
{isCountdown ? '还有' : '已经'}
</Typography>
<CountdownTimer
targetDate={new Date('2025-01-29T00:00:00')}
onUpdate={({ isCountdown }) => setIsCountdown(isCountdown)}
/>
</Box>
</Container>
)
}

View File

@ -1,4 +1,6 @@
import { sni } from '@/services/network'
'use client'
import { sni } from 'solar-js-sdk'
import { Container, Box, Typography, CircularProgress, Alert, Collapse } from '@mui/material'
import { useRouter } from 'next/router'
import { useEffect, useState } from 'react'
@ -6,16 +8,18 @@ import { useEffect, useState } from 'react'
import ErrorIcon from '@mui/icons-material/Error'
import 'animate.css'
import { useSearchParams } from 'next/navigation'
export default function AccountConfirm() {
const router = useRouter()
const searchParams = useSearchParams()
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,
code: searchParams.get('code'),
})
router.push('/')
} catch (err: any) {
@ -25,7 +29,7 @@ export default function AccountConfirm() {
useEffect(() => {
confirm()
}, [])
}, [searchParams])
return (
<Container

View File

@ -1,12 +1,16 @@
import { sni } from '@/services/network'
'use client'
import { sni } from 'solar-js-sdk'
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'
import { useSearchParams } from 'next/navigation'
export default function AccountDeletion() {
const router = useRouter()
const searchParams = useSearchParams()
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
@ -15,7 +19,7 @@ export default function AccountDeletion() {
try {
setBusy(true)
await sni.patch('/cgi/id/users/me/deletion', {
code: router.query['code'] as string,
code: searchParams.get('code'),
})
router.push('/')
} catch (err: any) {

View File

@ -1,10 +1,13 @@
import { sni } from '@/services/network'
'use client'
import { sni } from 'solar-js-sdk'
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'
import { useSearchParams } from 'next/navigation'
export type SnResetPasswordForm = {
password: string
@ -12,6 +15,7 @@ export type SnResetPasswordForm = {
export default function AccountPasswordReset() {
const router = useRouter()
const searchParams = useSearchParams()
const { handleSubmit, register } = useForm<SnResetPasswordForm>()
@ -22,7 +26,7 @@ export default function AccountPasswordReset() {
try {
setBusy(true)
await sni.patch('/cgi/id/users/me/password-reset', {
code: router.query['code'] as string,
code: searchParams.get('code'),
new_password: data.password,
})
router.push('/')

View File

@ -0,0 +1,11 @@
import { Typography, Container } from '@mui/material'
export default function MatrixMarketplace() {
return (
<Container sx={{ py: 24, display: 'flex', flexDirection: 'column', gap: 32 }}>
<Typography variant="h3" component="h1">
Matrix Marketplace
</Typography>
</Container>
)
}

118
src/pages/orders/[id].tsx Normal file
View File

@ -0,0 +1,118 @@
import { Box, Typography, Container, Button, TextField, Collapse, Alert } from '@mui/material'
import { GetServerSideProps, InferGetServerSidePropsType } from 'next'
import { EventHandler, FormEvent, FormEventHandler, useEffect, useState } from 'react'
import { checkAuthenticatedClient, redirectToLogin, sni } from 'solar-js-sdk'
import ErrorIcon from '@mui/icons-material/Error'
import PriceCheckIcon from '@mui/icons-material/PriceCheck'
type SnOrder = any
export const getServerSideProps = (async (context) => {
const id = context.params!.id
try {
const { data: order } = await sni.get<SnOrder>('/cgi/wa/orders/' + id)
return { props: { order, title: `Order #${order.id}` } }
} catch (err) {
console.error(err)
return {
notFound: true,
}
}
}) satisfies GetServerSideProps<{ order: SnOrder }>
export default function Post({ order }: InferGetServerSidePropsType<typeof getServerSideProps>) {
useEffect(() => {
if (!checkAuthenticatedClient()) redirectToLogin()
}, [])
const [error, setError] = useState<string | null>(null)
const [password, setPassword] = useState<string>('')
const [busy, setBusy] = useState(false)
const [paid, setPaid] = useState(false)
const [canceled, setCanceled] = useState(false)
useEffect(() => {
if (order?.status === 1) {
setPaid(true)
} else if (order?.status === 2) {
setCanceled(true)
}
}, [order])
async function confirmPayment() {
try {
setBusy(true)
await sni.post('/cgi/wa/orders/' + order.id + '/pay', {
wallet_password: password,
})
setPaid(true)
} 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
component="form"
sx={{ width: '100%' }}
onSubmit={(evt) => {
evt.preventDefault()
confirmPayment()
}}
>
<Typography variant="h5" component="h1" gutterBottom>
Order <code>#{order.id.toString().padStart(8, '0')}</code>
</Typography>
<Typography variant="body1" component="h2" gutterBottom>
{order.remark}
</Typography>
<Typography variant="body2" fontSize={32} pt={2} fontFamily={'monospace'} gutterBottom>
{order.amount} SRC
</Typography>
<Collapse in={!!error}>
<Alert sx={{ mt: 3, width: '100%' }} icon={<ErrorIcon fontSize="inherit" />} severity="error">
{error}
</Alert>
</Collapse>
<Box sx={{ my: 3, flexDirection: 'column', display: 'flex', gap: 2 }}>
{paid || canceled ? (
canceled ? (
<Typography textAlign="center">Canceled, you are not able to pay this order any more</Typography>
) : (
<Typography textAlign="center">Paid, you can return to the seller now</Typography>
)
) : (
<TextField
label="Wallet Password"
variant="outlined"
type="password"
onInput={(evt) => setPassword((evt.target as HTMLInputElement).value)}
/>
)}
<Button type="submit" variant="contained" startIcon={<PriceCheckIcon />} disabled={busy || paid}>
Pay
</Button>
</Box>
<Typography variant="caption" sx={{ opacity: 0.75 }}>
Powered by HyperNet.Wallet
</Typography>
</Box>
</Container>
)
}

View File

@ -1,6 +1,6 @@
import { getAttachmentUrl, sni } from '@/services/network'
import { SnPost } from '@/services/post'
import { listAttachment, SnAttachment } from '@/services/attachment'
import { getAttachmentUrl, sni } from 'solar-js-sdk'
import { SnPost } from 'solar-js-sdk'
import { listAttachment, SnAttachment } from 'solar-js-sdk'
import {
Grid2 as Grid,
Alert,

View File

@ -1,5 +1,5 @@
import { sni } from '@/services/network'
import { SnPost } from '@/services/post'
import { sni } from 'solar-js-sdk'
import { SnPost } from 'solar-js-sdk'
import { GetServerSideProps } from 'next'
import { Feed } from 'feed'

View File

@ -1,7 +1,7 @@
import { AttachmentItem } from '@/components/attachments/AttachmentItem'
import { SnAttachment, listAttachment } from '@/services/attachment'
import { getAttachmentUrl, sni } from '@/services/network'
import { SnPost } from '@/services/post'
import { SnAttachment, listAttachment } from 'solar-js-sdk'
import { getAttachmentUrl, sni } from 'solar-js-sdk'
import { SnPost } from 'solar-js-sdk'
import { Avatar, Box, Container, Divider, Grid2 as Grid, Link, Pagination, Paper, Typography } from '@mui/material'
import { GetServerSideProps, InferGetServerSidePropsType } from 'next'
import NextLink from 'next/link'

View File

@ -1,5 +1,5 @@
import { sni } from '@/services/network'
import { SnPost } from '@/services/post'
import { sni } from 'solar-js-sdk'
import { SnPost } from 'solar-js-sdk'
import { GetServerSideProps } from 'next'
import { EnumChangefreq, SitemapItem, SitemapStream, streamToPromise } from 'sitemap'
import { Readable } from 'stream'

View File

@ -12,9 +12,13 @@ import {
Accordion,
AccordionDetails,
AccordionSummary,
Grid2 as Grid,
Card,
CardContent,
} from '@mui/material'
import { JSX } from 'react'
import { Roboto_Serif } from 'next/font/google'
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
import Image from 'next/image'
import NextLink from 'next/link'
@ -26,11 +30,24 @@ 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 SearchIcon from '@mui/icons-material/Search'
import GitHubIcon from '@mui/icons-material/GitHub'
import SecurityIcon from '@mui/icons-material/Security'
import CookieIcon from '@mui/icons-material/Cookie'
import ComputerIcon from '@mui/icons-material/Computer';
import ImgSolarNetworkIcon from '@/assets/products/solar-network/icon.png'
import ImgSolarNetworkAlpha from '@/assets/products/solar-network/alpha.webp'
import ImgFtDashboard from '@/assets/products/solar-network/ft-dashboard.png'
import ImgFtExplore from '@/assets/products/solar-network/ft-explore.png'
import ImgFtChat from '@/assets/products/solar-network/ft-chat.png'
import ImgFtNews from '@/assets/products/solar-network/ft-news.png'
import ImgFtStickers from '@/assets/products/solar-network/ft-stickers.png'
import ImgFtPosting from '@/assets/products/solar-network/ft-posting.png'
import 'animate.css'
import { useTranslation } from 'next-i18next'
interface DownloadableAsset {
icon: JSX.Element
@ -51,68 +68,79 @@ const fontSerif = Roboto_Serif({
style: 'italic',
})
export async function getStaticProps() {
export async function getStaticProps({ locale }: { locale: string }) {
return {
props: {
title: 'Solar Network',
...(await serverSideTranslations(locale, ['common', 'product-solar-network'])),
},
}
}
export default function ProductSolarNetwork() {
const { t: ct } = useTranslation('common')
const { t } = useTranslation('product-solar-network')
const downloadableAssets: DownloadableAsset[] = [
{
icon: <AppleIcon />,
title: 'iOS / macOS (App Store)',
title: ct('downloadAppleStore'),
href: 'https://apps.apple.com/us/app/solian/id6499032345?itscg=30200&itsct=apps_box_link&mttnsubad=6499032345',
},
{
icon: <AppleIcon />,
title: 'iOS / macOS (TestFlight)',
title: ct('downloadAppleTestflight'),
href: 'https://testflight.apple.com/join/YJ0lmN6O',
},
{
icon: <AndroidIcon />,
title: 'Android',
href: 'https://files.solsynth.dev/production01/solian/app-arm64-v8a-release.apk',
title: ct('downloadAndroid'),
href: 'https://github.com/Solsynth/HyperNet.Surface/releases/latest',
},
{
icon: <WindowIcon />,
title: 'Windows',
href: 'https://files.solsynth.dev/production01/solian/windows-x86_64-release.zip',
title: ct('downloadWindows'),
href: 'https://github.com/Solsynth/HyperNet.Surface/releases/latest',
},
{
icon: <ComputerIcon />,
title: ct('downloadLinux'),
href: 'https://github.com/Solsynth/HyperNet.Surface/releases/latest',
},
{
icon: <ComputerIcon />,
title: ct('downloadLinuxDebian'),
href: 'https://github.com/Solsynth/HyperNet.Surface/releases/latest',
},
{
icon: <WebIcon />,
title: 'Web',
title: ct('downloadWeb'),
href: 'https://sn.solsynth.dev',
open: true,
},
{
icon: <CodeIcon />,
title: 'Source Code',
title: ct('downloadSourceCode'),
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: t('faq1'),
answer: t('faq1a'),
},
{
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: t('faq2'),
answer: t('faq2a'),
},
{
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: t('faq3'),
answer: t('faq3a'),
},
{
question: 'If I have any question about Solar Network, where can I get help?',
answer: 'Feel free to email as at lily@solsynth.dev',
question: t('faq4'),
answer: t('faq4a'),
},
]
@ -129,7 +157,7 @@ export default function ProductSolarNetwork() {
/>
<Box position="relative" width="fit-content" className="animate__animated animate__fadeInUp">
<Typography variant="h4" component="h1">
Solar Network
{t('appName')}
</Typography>
<Box
position="absolute"
@ -139,14 +167,14 @@ export default function ProductSolarNetwork() {
className="animate__animated animate__pulse animate__infinite"
>
<Chip
label="2.0"
label="2.2"
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.
{t('appDescription')}
</Typography>
<Typography
@ -156,29 +184,218 @@ export default function ProductSolarNetwork() {
sx={{ mt: 2.5, width: 'fit-content', fontStyle: 'italic' }}
className="textmarker-effect animate__animated animate__fadeInUp"
>
Social Network, Redefined.
{t('appSlogan')}
</Typography>
<Link href="#download" sx={{ my: 2.5 }}>
Download <DownloadIcon sx={{ fontSize: 15, marginLeft: 0.5 }} />
</Link>
<Box display="flex" gap={2}>
<Link href="#features" sx={{ my: 2.5 }}>
{ct('actionLearnMore')} <SearchIcon sx={{ fontSize: 15, marginLeft: 0.5 }} />
</Link>
<Link href="#download" sx={{ my: 2.5 }}>
{ct('actionDownload')} <DownloadIcon sx={{ fontSize: 15, marginLeft: 0.5 }} />
</Link>
</Box>
<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="features" display="flex" flexDirection="column" gap={12}>
<Grid container columns={{ xs: 1, md: 2 }} spacing={4} alignItems="center">
<Grid size={1}>
<Typography variant="h5" component="h3" fontWeight="bold" gutterBottom>
{t('ftDashboard')}
</Typography>
<Typography variant="body1" fontSize={18}>
{t('ftDashboardDescription')}
</Typography>
</Grid>
<Grid size={1}>
<Box position="relative" borderRadius="4px" width="100%" sx={{ aspectRatio: 16 / 9 }} className="shadow-xl">
<Image
src={ImgFtDashboard}
alt="solar network dashboard"
fill
style={{ objectFit: 'cover', borderRadius: '8px' }}
/>
</Box>
</Grid>
</Grid>
<Grid container columns={{ xs: 1, md: 2 }} spacing={4} alignItems="center">
<Grid size={1}>
<Box position="relative" borderRadius="4px" width="100%" sx={{ aspectRatio: 16 / 9 }} className="shadow-xl">
<Image
src={ImgFtExplore}
alt="solar network explore"
fill
style={{ objectFit: 'cover', borderRadius: '8px' }}
/>
</Box>
</Grid>
<Grid size={1} textAlign={{ xs: 'left', md: 'right' }} order={{ xs: -1, md: 1 }}>
<Typography variant="h5" component="h3" fontWeight="bold" gutterBottom>
{t('ftExplore')}
</Typography>
<Typography variant="body1" fontSize={18}>
{t('ftExploreDescription')}
</Typography>
</Grid>
</Grid>
<Grid container columns={{ xs: 1, md: 2 }} spacing={4} alignItems="center">
<Grid size={1}>
<Typography variant="h5" component="h3" fontWeight="bold" gutterBottom>
{t('ftChat')}
</Typography>
<Typography variant="body1" fontSize={18}>
{t('ftChatDescription')}
</Typography>
</Grid>
<Grid size={1}>
<Box position="relative" borderRadius="4px" width="100%" sx={{ aspectRatio: 16 / 9 }} className="shadow-xl">
<Image
src={ImgFtChat}
alt="solar network chat"
fill
style={{ objectFit: 'cover', borderRadius: '8px' }}
/>
</Box>
</Grid>
</Grid>
<Grid container columns={{ xs: 1, md: 2 }} spacing={4} alignItems="center">
<Grid size={1}>
<Box position="relative" borderRadius="4px" width="100%" sx={{ aspectRatio: 16 / 9 }} className="shadow-xl">
<Image
src={ImgFtNews}
alt="solar network news"
fill
style={{ objectFit: 'cover', borderRadius: '8px' }}
/>
</Box>
</Grid>
<Grid size={1} textAlign={{ xs: 'left', md: 'right' }} order={{ xs: -1, md: 1 }}>
<Typography variant="h5" component="h3" fontWeight="bold" gutterBottom>
{t('ftNews')}
</Typography>
<Typography variant="body1" fontSize={18}>
{t('ftNewsDescription')}
</Typography>
</Grid>
</Grid>
<Grid container columns={{ xs: 1, md: 2 }} spacing={4} alignItems="center">
<Grid size={1}>
<Typography variant="h5" component="h3" fontWeight="bold" gutterBottom>
{t('ftStickers')}
</Typography>
<Typography variant="body1" fontSize={18}>
{t('ftStickersDescription')}
</Typography>
</Grid>
<Grid size={1}>
<Box position="relative" borderRadius="4px" width="100%" sx={{ aspectRatio: 16 / 9 }} className="shadow-xl">
<Image
src={ImgFtStickers}
alt="solar network stickers"
fill
style={{ objectFit: 'cover', borderRadius: '8px' }}
/>
</Box>
</Grid>
</Grid>
<Grid container columns={{ xs: 1, md: 2 }} spacing={4} alignItems="center">
<Grid size={1}>
<Box position="relative" borderRadius="4px" width="100%" sx={{ aspectRatio: 16 / 9 }} className="shadow-xl">
<Image
src={ImgFtPosting}
alt="solar network posting"
fill
style={{ objectFit: 'cover', borderRadius: '8px' }}
/>
</Box>
</Grid>
<Grid size={1} textAlign={{ xs: 'left', md: 'right' }} order={{ xs: -1, md: 1 }}>
<Typography variant="h5" component="h3" fontWeight="bold" gutterBottom>
{t('ftPosting')}
</Typography>
<Typography variant="body1" fontSize={18} gutterBottom>
{t('ftPostingDescription')}
</Typography>
<Typography variant="caption">*{t('ftPostingDescriptionAddition')}</Typography>
</Grid>
</Grid>
<Box>
<Typography variant="h5" component="h2" textAlign="center" sx={{ my: 5 }}>
{t('whatsMore')}
</Typography>
<Grid container columns={{ xs: 1, sm: 2, md: 3 }} spacing={4}>
<Grid size={1}>
<Card variant="outlined">
<CardContent>
<GitHubIcon sx={{ fontSize: 40, mb: 1 }} />
<Typography variant="h6" component="h5" gutterBottom>
{t('ftOpenSource')}
</Typography>
<Typography variant="body2">{t('ftOpenSourceDescription')}</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={1}>
<Card variant="outlined">
<CardContent>
<SecurityIcon sx={{ fontSize: 40, mb: 1 }} />
<Typography variant="h6" component="h5" gutterBottom>
{t('ftSecurity')}
</Typography>
<Typography variant="body2">{t('ftSecurityDescription')}</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={1}>
<Card variant="outlined">
<CardContent>
<CookieIcon sx={{ fontSize: 40, mb: 1 }} />
<Typography variant="h6" component="h5" gutterBottom>
{t('ftNoCollecting')}
</Typography>
<Typography variant="body2">{t('ftNoCollectingDescription')}</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
<Box textAlign="center">
<Typography variant="h5" component="h2" fontWeight="bold" sx={{ mt: 5 }} gutterBottom>
{t('noWaiting')}
</Typography>
<Typography variant="body1" sx={{ mb: 2 }}>
{t('noWaitingDescription')}
</Typography>
<Link href="#download" sx={{ my: 2.5 }}>
{ct('actionDownload')} <DownloadIcon sx={{ fontSize: 15, marginLeft: 0.5 }} />
</Link>
</Box>
</Box>
<Box id="download">
<Typography variant="h5" component="h2" textAlign="center" sx={{ mb: 5 }}>
Download
{ct('actionDownload')}
</Typography>
<Table sx={{ maxWidth: '800px', marginX: 'auto' }} aria-label="download table">
<TableHead>
<TableRow>
<TableCell />
<TableCell>Platform</TableCell>
<TableCell align="right">Distribution</TableCell>
<TableCell>{ct('downloadPlatform')}</TableCell>
<TableCell align="right">{ct('downloadDistribution')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
@ -190,12 +407,12 @@ export default function ProductSolarNetwork() {
<NextLink passHref href={a.href} target="_blank">
{a.open ? (
<Link component="span">
Open now
{ct('actionOpen')}
<LaunchIcon sx={{ fontSize: 15, marginLeft: 0.5 }} />
</Link>
) : (
<Link component="span">
Download now
{ct('actionDownload')}
<DownloadIcon sx={{ fontSize: 15, marginLeft: 0.5 }} />
</Link>
)}
@ -209,7 +426,7 @@ export default function ProductSolarNetwork() {
<Box id="faq">
<Typography variant="h5" component="h2" textAlign="center" sx={{ mb: 5 }}>
Frequently Asked Questions
{ct('faq')}
</Typography>
<Box sx={{ maxWidth: '800px', marginX: 'auto' }}>

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

@ -0,0 +1,228 @@
import {
Alert,
Avatar,
Box,
Button,
Card,
CardContent,
Checkbox,
Collapse,
Container,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Typography,
useTheme,
} from '@mui/material'
import { GetServerSideProps } from 'next'
import { checkAuthenticatedClient, getAttachmentUrl, redirectToLogin, sni, useUserStore } from 'solar-js-sdk'
import { useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { QRCodeSVG } from 'qrcode.react'
import PublicIcon from '@mui/icons-material/Public'
import ErrorIcon from '@mui/icons-material/Error'
export const getServerSideProps = (async (context) => {
const id = context.params!.id as string[]
try {
const { data: realm } = await sni.get<any>('/cgi/id/realms/' + id)
return { props: { realm, title: `Realm ${realm.name} / Solar Network` } }
} catch (err) {
console.error(err)
return {
notFound: true,
}
}
}) satisfies GetServerSideProps<{ realm: any }>
export default function Realm({ realm }: any) {
useEffect(() => {
if (!checkAuthenticatedClient()) redirectToLogin()
}, [])
const user = useUserStore()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [joined, setJoined] = useState(false)
const [publicChannels, setPublicChannels] = useState<any[]>([])
const [checkedChannels, setCheckedChannels] = useState<string[]>([])
const searchParams = useSearchParams()
const isShare = useMemo(() => searchParams.has('share'), [searchParams])
function handleCheckChannel(value: string) {
const currentIndex = checkedChannels.indexOf(value)
const newChecked = [...checkedChannels]
if (currentIndex === -1) {
newChecked.push(value)
} else {
newChecked.splice(currentIndex, 1)
}
setCheckedChannels(newChecked)
}
async function fetchPublicChannels() {
try {
const { data: channels } = await sni.get<any>('/cgi/im/channels/' + realm.alias)
setPublicChannels(channels)
} catch (err) {
console.error(err)
}
}
useEffect(() => {
fetchPublicChannels()
}, [realm])
async function joinRealm() {
setLoading(true)
try {
await sni.post('/cgi/id/realms/' + realm.id + '/members', {
related: user.account!.name,
})
setLoading(false)
await joinChannels()
setJoined(true)
} catch (err: any) {
console.error(err)
setError(err.toString())
} finally {
setLoading(false)
}
}
async function joinChannels() {
for (const chan of checkedChannels) {
try {
await sni.post('/cgi/im/channels/' + realm.alias + '/' + chan + '/members', {
related: user.account!.name,
})
} catch (err: any) {
console.error(err)
}
}
}
const theme = useTheme()
return (
<Box
sx={{
display: 'grid',
placeItems: 'center',
height: '100vh',
paddingTop: '64px',
marginTop: '-64px',
backgroundImage: `url(${getAttachmentUrl(realm.banner ?? '')})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
>
<Container maxWidth="xs">
<Card
variant="outlined"
className="backdrop-blur-lg"
sx={{ backgroundColor: 'rgba(255, 255, 255, .2)', borderRadius: '16px' }}
>
<CardContent sx={{ px: 5, pt: 5, pb: 12 }}>
<Avatar className="shadow-md" sx={{ width: 64, height: 64 }} src={getAttachmentUrl(realm.avatar ?? '')} />
<Typography sx={{ mt: 3 }} variant="h5">
{realm.name}
</Typography>
<Typography fontSize={13} fontFamily="monospace">
@{realm.alias}
</Typography>
<Typography sx={{ mt: 3 }}>{realm.description}</Typography>
{isShare ? (
<Box mt={3} mx="auto" width={256}>
<QRCodeSVG
title="Realm QR Code"
value={'https://solsynth.dev/realms/' + realm.alias}
level="H"
bgColor="#00000000"
fgColor={theme.palette.text.primary}
size={256}
/>
<Typography textAlign="center" mt={2}>
Scan the QR Code above to join
</Typography>
</Box>
) : (
<>
{publicChannels.length > 0 && (
<Box sx={{ mt: 3 }}>
<Typography fontSize={14} mx={1} sx={{ opacity: 0.75, mb: 0.5, textAlign: 'center' }}>
Public channels in this realm you can join
</Typography>
<List sx={{ width: '100%', p: 0, borderRadius: '8px', bgcolor: 'rgba(255, 255, 255, .2)' }}>
{publicChannels.map((value) => {
const labelId = `checkbox-list-label-${value}`
return (
<ListItem key={value.id} disablePadding>
<ListItemButton
sx={{ borderRadius: '8px' }}
onClick={() => handleCheckChannel(value.alias)}
dense
>
<ListItemIcon>
<Checkbox
edge="start"
checked={checkedChannels.includes(value.alias)}
tabIndex={-1}
disableRipple
inputProps={{ 'aria-labelledby': labelId }}
/>
</ListItemIcon>
<ListItemText id={labelId} primary={value.name} />
</ListItemButton>
</ListItem>
)
})}
</List>
</Box>
)}
{realm.isCommunity && (
<Box sx={{ mt: 3 }}>
<Box display="flex" sx={{ opacity: 0.75 }}>
<PublicIcon />
<Typography sx={{ ml: 1 }} variant="body2">
A community realm, you can join it as you wish.
</Typography>
</Box>
</Box>
)}
<Collapse in={!!error} sx={{ width: '100%' }}>
<Alert sx={{ mt: 3 }} icon={<ErrorIcon fontSize="inherit" />} severity="error">
{error}
</Alert>
</Collapse>
{joined ? (
<Alert severity="info" sx={{ mt: 2.5 }}>
Joined, check it out in the app
</Alert>
) : (
<Button fullWidth variant="contained" disabled={loading} sx={{ mt: 3 }} onClick={joinRealm}>
Join
</Button>
)}
</>
)}
</CardContent>
</Card>
</Container>
</Box>
)
}

View File

@ -57,6 +57,11 @@ export default function PrivacyPolicy() {
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="the-branding-issue">The Impersonating Issue</h4>
<p>
You cannot impersonating us (the Solsynth LLC) in anyways. For example like using our logos, our product name,
or our name. Otherwise we have the right to remove / suspend your account.
</p>
<h4 id="restriction-and-discontinuation">Restriction and Discontinuation</h4>
<ul>
<li>

View File

@ -1,9 +1,10 @@
import { SnCheckInRecord } from '@/services/checkIn'
import { getAttachmentUrl, sni } from '@/services/network'
import { SnAccount, SnAccountBadgeMapping } from '@/services/user'
import { SnCheckInRecord } from 'solar-js-sdk'
import { getAttachmentUrl, sni } from 'solar-js-sdk'
import { SnAccount } from 'solar-js-sdk'
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 { SnAccountBadgeMapping } from '@/services/user'
import Image from 'next/image'
export const getServerSideProps = (async (context) => {

View File

@ -1,14 +1,14 @@
import { checkAuthenticatedClient, redirectToLogin } from '@/services/auth'
import { useUserStore } from '@/services/user'
import { checkAuthenticatedClient, redirectToLogin } from 'solar-js-sdk'
import { useUserStore } from 'solar-js-sdk'
import { Avatar, Box, Button, Container, Typography } from '@mui/material'
import { getAttachmentUrl } from '@/services/network'
import { getAttachmentUrl } from 'solar-js-sdk'
import { useEffect } from 'react'
import { removeTokenCookies } from 'solar-js-sdk'
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(() => {
@ -18,8 +18,7 @@ export default function UserItself() {
const userStore = useUserStore()
function logout() {
deleteCookie('nex_user_atk')
deleteCookie('nex_user_rtk')
removeTokenCookies()
window.location.reload()
}

View File

@ -1,48 +0,0 @@
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
}

View File

@ -1,77 +0,0 @@
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 const 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}`
}

View File

@ -1,69 +1,8 @@
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 />,
@ -74,24 +13,3 @@ export const SnAccountBadgeMapping: Record<string, { icon: JSX.Element; name: st
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
}
},
}))

View File

@ -14,5 +14,5 @@ export default {
},
},
},
plugins: [require('@tailwindcss/typography')],
plugins: [require('@tailwindcss/typography'), require('daisyui')],
} satisfies Config

View File

@ -17,6 +17,6 @@
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "next-i18next.config.js"],
"exclude": ["node_modules"]
}