Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bf37ba510 | |||
| 3c04b5db26 | |||
| 45e0b09af8 | |||
| 106212ef3d |
43
.gitea/workflows/deploy-web-apps.yml
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: Deploy Web Apps
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: windows
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: powershell
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: https://github.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install root dependencies
|
||||||
|
env:
|
||||||
|
NODE_ENV: development
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install website dependencies
|
||||||
|
env:
|
||||||
|
NODE_ENV: development
|
||||||
|
run: npm ci --prefix website
|
||||||
|
|
||||||
|
- name: Build Toju web app
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Build Toju website
|
||||||
|
run: |
|
||||||
|
Push-Location website
|
||||||
|
npm run build
|
||||||
|
Pop-Location
|
||||||
|
|
||||||
|
- name: Deploy both apps to IIS
|
||||||
|
run: >
|
||||||
|
./tools/deploy-web-apps.ps1
|
||||||
|
-WebsitePort 4341
|
||||||
|
-AppPort 4492
|
||||||
23
public/web.config
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<system.webServer>
|
||||||
|
<staticContent>
|
||||||
|
<remove fileExtension=".wasm" />
|
||||||
|
<remove fileExtension=".webmanifest" />
|
||||||
|
<mimeMap fileExtension=".wasm" mimeType="application/wasm" />
|
||||||
|
<mimeMap fileExtension=".webmanifest" mimeType="application/manifest+json" />
|
||||||
|
</staticContent>
|
||||||
|
<rewrite>
|
||||||
|
<rules>
|
||||||
|
<rule name="Angular Routes" stopProcessing="true">
|
||||||
|
<match url=".*" />
|
||||||
|
<conditions logicalGrouping="MatchAll">
|
||||||
|
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
|
||||||
|
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
|
||||||
|
</conditions>
|
||||||
|
<action type="Rewrite" url="/index.html" />
|
||||||
|
</rule>
|
||||||
|
</rules>
|
||||||
|
</rewrite>
|
||||||
|
</system.webServer>
|
||||||
|
</configuration>
|
||||||
@@ -92,7 +92,7 @@ function buildDefaultServerUrl(): string {
|
|||||||
|
|
||||||
/** Blueprint for the built-in default endpoint. */
|
/** Blueprint for the built-in default endpoint. */
|
||||||
const DEFAULT_ENDPOINT: Omit<ServerEndpoint, 'id'> = {
|
const DEFAULT_ENDPOINT: Omit<ServerEndpoint, 'id'> = {
|
||||||
name: 'Local Server',
|
name: 'Default Server',
|
||||||
url: buildDefaultServerUrl(),
|
url: buildDefaultServerUrl(),
|
||||||
isActive: true,
|
isActive: true,
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const environment = {
|
export const environment = {
|
||||||
production: true,
|
production: true,
|
||||||
defaultServerUrl: 'https://tojusignal.azaaxin.com'
|
defaultServerUrl: 'https://signal.toju.app'
|
||||||
};
|
};
|
||||||
|
|||||||
105
tools/deploy-web-apps.ps1
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$RepoRoot = (Split-Path -Parent $PSScriptRoot),
|
||||||
|
[string]$IisRoot = 'C:\inetpub\wwwroot',
|
||||||
|
[int]$WebsitePort = 4341,
|
||||||
|
[int]$AppPort = 4492
|
||||||
|
)
|
||||||
|
|
||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
try {
|
||||||
|
Import-Module WebAdministration -ErrorAction Stop
|
||||||
|
} catch {
|
||||||
|
throw 'The IIS WebAdministration module is required on the Windows runner.'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-RoboCopyMirror {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Source,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Destination
|
||||||
|
)
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $Source)) {
|
||||||
|
throw "Build output not found: $Source"
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $Destination -Force | Out-Null
|
||||||
|
robocopy $Source $Destination /MIR /NFL /NDL /NJH /NJS /NP | Out-Null
|
||||||
|
|
||||||
|
if ($LASTEXITCODE -gt 7) {
|
||||||
|
throw "robocopy failed from $Source to $Destination with exit code $LASTEXITCODE"
|
||||||
|
}
|
||||||
|
|
||||||
|
$global:LASTEXITCODE = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ensure-AppPool {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Name
|
||||||
|
)
|
||||||
|
|
||||||
|
$appPoolPath = "IIS:\AppPools\$Name"
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $appPoolPath)) {
|
||||||
|
New-WebAppPool -Name $Name | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-ItemProperty $appPoolPath -Name managedRuntimeVersion -Value ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function Publish-IisSite {
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$SiteName,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$SourcePath,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$DestinationPath,
|
||||||
|
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[int]$Port
|
||||||
|
)
|
||||||
|
|
||||||
|
Ensure-AppPool -Name $SiteName
|
||||||
|
Invoke-RoboCopyMirror -Source $SourcePath -Destination $DestinationPath
|
||||||
|
|
||||||
|
$existingSite = Get-Website -Name $SiteName -ErrorAction SilentlyContinue
|
||||||
|
if ($null -ne $existingSite) {
|
||||||
|
Stop-Website -Name $SiteName -ErrorAction SilentlyContinue
|
||||||
|
Remove-Website -Name $SiteName
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Website -Name $SiteName -PhysicalPath $DestinationPath -Port $Port -ApplicationPool $SiteName | Out-Null
|
||||||
|
Start-Website -Name $SiteName
|
||||||
|
|
||||||
|
Write-Host "Deployed $SiteName to $DestinationPath on port $Port."
|
||||||
|
}
|
||||||
|
|
||||||
|
$deployments = @(
|
||||||
|
@{
|
||||||
|
SiteName = 'toju-website'
|
||||||
|
SourcePath = (Join-Path $RepoRoot 'website\dist\toju-website\browser')
|
||||||
|
DestinationPath = (Join-Path $IisRoot 'toju-website')
|
||||||
|
Port = $WebsitePort
|
||||||
|
},
|
||||||
|
@{
|
||||||
|
SiteName = 'toju-app'
|
||||||
|
SourcePath = (Join-Path $RepoRoot 'dist\client\browser')
|
||||||
|
DestinationPath = (Join-Path $IisRoot 'toju-app')
|
||||||
|
Port = $AppPort
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($deployment in $deployments) {
|
||||||
|
Publish-IisSite @deployment
|
||||||
|
}
|
||||||
|
|
||||||
|
$global:LASTEXITCODE = 0
|
||||||
17
website/.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Editor configuration, see https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.ts]
|
||||||
|
quote_type = single
|
||||||
|
ij_typescript_use_double_quotes = false
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
||||||
42
website/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/.angular/cache
|
||||||
|
.sass-cache/
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
4
website/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||||
|
"recommendations": ["angular.ng-template"]
|
||||||
|
}
|
||||||
20
website/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "ng serve",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "npm: start",
|
||||||
|
"url": "http://localhost:4200/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ng test",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "npm: test",
|
||||||
|
"url": "http://localhost:9876/debug.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
42
website/.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "start",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": {
|
||||||
|
"owner": "typescript",
|
||||||
|
"pattern": "$tsc",
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": {
|
||||||
|
"regexp": "(.*?)"
|
||||||
|
},
|
||||||
|
"endsPattern": {
|
||||||
|
"regexp": "bundle generation complete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "test",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": {
|
||||||
|
"owner": "typescript",
|
||||||
|
"pattern": "$tsc",
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": {
|
||||||
|
"regexp": "(.*?)"
|
||||||
|
},
|
||||||
|
"endsPattern": {
|
||||||
|
"regexp": "bundle generation complete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
59
website/README.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# TojuWebsite
|
||||||
|
|
||||||
|
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.2.21.
|
||||||
|
|
||||||
|
## Development server
|
||||||
|
|
||||||
|
To start a local development server, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||||
|
|
||||||
|
## Code scaffolding
|
||||||
|
|
||||||
|
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate component component-name
|
||||||
|
```
|
||||||
|
|
||||||
|
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To build the project run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng build
|
||||||
|
```
|
||||||
|
|
||||||
|
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||||
|
|
||||||
|
## Running unit tests
|
||||||
|
|
||||||
|
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running end-to-end tests
|
||||||
|
|
||||||
|
For end-to-end (e2e) testing, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||||
|
|
||||||
|
## Additional Resources
|
||||||
|
|
||||||
|
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||||
148
website/angular.json
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"toju-website": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"style": "scss",
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:class": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:directive": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:guard": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:interceptor": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:pipe": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:resolver": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:service": {
|
||||||
|
"skipTests": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:application",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/toju-website",
|
||||||
|
"index": "src/index.html",
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"polyfills": [
|
||||||
|
"zone.js"
|
||||||
|
],
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "src/images",
|
||||||
|
"output": "/images"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.scss"
|
||||||
|
],
|
||||||
|
"scripts": [],
|
||||||
|
"server": "src/main.server.ts",
|
||||||
|
"security": {
|
||||||
|
"allowedHosts": []
|
||||||
|
},
|
||||||
|
"prerender": true,
|
||||||
|
"ssr": {
|
||||||
|
"entry": "src/server.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "1MB",
|
||||||
|
"maximumError": "2MB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "8kB",
|
||||||
|
"maximumError": "16kB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"options": {
|
||||||
|
"proxyConfig": "proxy.conf.json"
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "toju-website:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "toju-website:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"extract-i18n": {
|
||||||
|
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
|
"options": {
|
||||||
|
"polyfills": [
|
||||||
|
"zone.js",
|
||||||
|
"zone.js/testing"
|
||||||
|
],
|
||||||
|
"tsConfig": "tsconfig.spec.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "src/images",
|
||||||
|
"output": "/images/"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.scss"
|
||||||
|
],
|
||||||
|
"scripts": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cli": {
|
||||||
|
"analytics": false
|
||||||
|
}
|
||||||
|
}
|
||||||
15702
website/package-lock.json
generated
Normal file
50
website/package.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "toju-website",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test",
|
||||||
|
"serve:ssr:toju-website": "node dist/toju-website/server/server.mjs"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/common": "^19.2.0",
|
||||||
|
"@angular/compiler": "^19.2.0",
|
||||||
|
"@angular/core": "^19.2.0",
|
||||||
|
"@angular/forms": "^19.2.0",
|
||||||
|
"@angular/platform-browser": "^19.2.0",
|
||||||
|
"@angular/platform-browser-dynamic": "^19.2.0",
|
||||||
|
"@angular/platform-server": "^19.2.0",
|
||||||
|
"@angular/router": "^19.2.0",
|
||||||
|
"@angular/ssr": "^19.2.21",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@tsparticles/angular": "^3.0.0",
|
||||||
|
"@tsparticles/engine": "^3.9.1",
|
||||||
|
"@tsparticles/slim": "^3.9.1",
|
||||||
|
"autoprefixer": "^10.4.27",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"postcss": "^8.5.8",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"tslib": "^2.3.0",
|
||||||
|
"zone.js": "~0.15.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-devkit/build-angular": "^19.2.21",
|
||||||
|
"@angular/cli": "^19.2.21",
|
||||||
|
"@angular/compiler-cli": "^19.2.0",
|
||||||
|
"@types/express": "^4.17.17",
|
||||||
|
"@types/jasmine": "~5.1.0",
|
||||||
|
"@types/node": "^18.18.0",
|
||||||
|
"jasmine-core": "~5.6.0",
|
||||||
|
"karma": "~6.4.0",
|
||||||
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
"karma-coverage": "~2.2.0",
|
||||||
|
"karma-jasmine": "~5.1.0",
|
||||||
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
|
"typescript": "~5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
website/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
10
website/proxy.conf.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"/api/releases": {
|
||||||
|
"target": "https://git.azaaxin.com",
|
||||||
|
"secure": true,
|
||||||
|
"changeOrigin": true,
|
||||||
|
"pathRewrite": {
|
||||||
|
"^/api/releases": "/api/v1/repos/myxelium/Toju/releases"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
website/public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
website/public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 226 KiB |
BIN
website/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
website/public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 896 B |
BIN
website/public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
website/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
website/public/iconsan.png
Normal file
|
After Width: | Height: | Size: 308 KiB |
BIN
website/public/og-image.png
Normal file
|
After Width: | Height: | Size: 406 KiB |
4
website/public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://toju.app/sitemap.xml
|
||||||
1
website/public/site.webmanifest
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
27
website/public/sitemap.xml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://toju.app/</loc>
|
||||||
|
<lastmod>2026-03-12</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://toju.app/what-is-toju</loc>
|
||||||
|
<lastmod>2026-03-12</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://toju.app/downloads</loc>
|
||||||
|
<lastmod>2026-03-12</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://toju.app/philosophy</loc>
|
||||||
|
<lastmod>2026-03-12</lastmod>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
23
website/public/web.config
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<configuration>
|
||||||
|
<system.webServer>
|
||||||
|
<staticContent>
|
||||||
|
<remove fileExtension=".wasm" />
|
||||||
|
<remove fileExtension=".webmanifest" />
|
||||||
|
<mimeMap fileExtension=".wasm" mimeType="application/wasm" />
|
||||||
|
<mimeMap fileExtension=".webmanifest" mimeType="application/manifest+json" />
|
||||||
|
</staticContent>
|
||||||
|
<rewrite>
|
||||||
|
<rules>
|
||||||
|
<rule name="Angular Routes" stopProcessing="true">
|
||||||
|
<match url=".*" />
|
||||||
|
<conditions logicalGrouping="MatchAll">
|
||||||
|
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
|
||||||
|
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
|
||||||
|
</conditions>
|
||||||
|
<action type="Rewrite" url="/index.html" />
|
||||||
|
</rule>
|
||||||
|
</rules>
|
||||||
|
</rewrite>
|
||||||
|
</system.webServer>
|
||||||
|
</configuration>
|
||||||
8
website/src/app/app.component.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<app-particle-bg />
|
||||||
|
<div class="relative z-10 flex min-h-screen flex-col">
|
||||||
|
<app-header />
|
||||||
|
<main class="flex-1">
|
||||||
|
<router-outlet />
|
||||||
|
</main>
|
||||||
|
<app-footer />
|
||||||
|
</div>
|
||||||
3
website/src/app/app.component.scss
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
20
website/src/app/app.component.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
import { HeaderComponent } from './components/header/header.component';
|
||||||
|
import { FooterComponent } from './components/footer/footer.component';
|
||||||
|
import { ParticleBgComponent } from './components/particle-bg/particle-bg.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
RouterOutlet,
|
||||||
|
HeaderComponent,
|
||||||
|
FooterComponent,
|
||||||
|
ParticleBgComponent
|
||||||
|
],
|
||||||
|
templateUrl: './app.component.html',
|
||||||
|
styleUrl: './app.component.scss'
|
||||||
|
})
|
||||||
|
export class AppComponent {}
|
||||||
|
|
||||||
9
website/src/app/app.config.server.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { ApplicationConfig, mergeApplicationConfig } from '@angular/core';
|
||||||
|
import { provideServerRendering } from '@angular/platform-server';
|
||||||
|
import { appConfig } from './app.config';
|
||||||
|
|
||||||
|
const serverConfig: ApplicationConfig = {
|
||||||
|
providers: [provideServerRendering()]
|
||||||
|
};
|
||||||
|
|
||||||
|
export const config = mergeApplicationConfig(appConfig, serverConfig);
|
||||||
18
website/src/app/app.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||||
|
import { provideRouter, withInMemoryScrolling } from '@angular/router';
|
||||||
|
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
|
||||||
|
import { provideHttpClient, withFetch } from '@angular/common/http';
|
||||||
|
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
|
provideRouter(
|
||||||
|
routes,
|
||||||
|
withInMemoryScrolling({ scrollPositionRestoration: 'top', anchorScrolling: 'enabled' })
|
||||||
|
),
|
||||||
|
provideClientHydration(withEventReplay()),
|
||||||
|
provideHttpClient(withFetch())
|
||||||
|
]
|
||||||
|
};
|
||||||
38
website/src/app/app.routes.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
loadComponent: () => import('./pages/home/home.component').then(
|
||||||
|
(homePageModule) => homePageModule.HomeComponent
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'what-is-toju',
|
||||||
|
loadComponent: () => import('./pages/what-is-toju/what-is-toju.component').then(
|
||||||
|
(whatIsTojuPageModule) => whatIsTojuPageModule.WhatIsTojuComponent
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'downloads',
|
||||||
|
loadComponent: () => import('./pages/downloads/downloads.component').then(
|
||||||
|
(downloadsPageModule) => downloadsPageModule.DownloadsComponent
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'gallery',
|
||||||
|
loadComponent: () => import('./pages/gallery/gallery.component').then(
|
||||||
|
(galleryPageModule) => galleryPageModule.GalleryComponent
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'philosophy',
|
||||||
|
loadComponent: () => import('./pages/philosophy/philosophy.component').then(
|
||||||
|
(philosophyPageModule) => philosophyPageModule.PhilosophyComponent
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '**',
|
||||||
|
redirectTo: ''
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
@if (adService.adsEnabled()) {
|
||||||
|
<div class="container mx-auto px-6 py-4">
|
||||||
|
<div
|
||||||
|
class="rounded-lg border border-dashed border-border/50 bg-card/30 min-h-[90px] flex items-center justify-center text-xs text-muted-foreground/50"
|
||||||
|
>
|
||||||
|
Advertisement
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
11
website/src/app/components/ad-slot/ad-slot.component.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { AdService } from '../../services/ad.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-ad-slot',
|
||||||
|
standalone: true,
|
||||||
|
templateUrl: './ad-slot.component.html'
|
||||||
|
})
|
||||||
|
export class AdSlotComponent {
|
||||||
|
readonly adService = inject(AdService);
|
||||||
|
}
|
||||||
171
website/src/app/components/footer/footer.component.html
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
<footer class="border-t border-border/30 bg-background/80 backdrop-blur-sm">
|
||||||
|
<div class="container mx-auto px-6 py-16">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-12">
|
||||||
|
<!-- Brand -->
|
||||||
|
<div class="md:col-span-1">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<img
|
||||||
|
src="/images/toju-logo-transparent.png"
|
||||||
|
alt="Toju"
|
||||||
|
class="h-8 w-auto object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
Free, open-source, peer-to-peer communication. Built by people who believe privacy is a right, not a premium feature.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Product -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold text-foreground mb-4 uppercase tracking-wider">Product</h4>
|
||||||
|
<ul class="space-y-3">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
routerLink="/downloads"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Downloads
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://web.toju.app/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Web Version
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
routerLink="/what-is-toju"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
What is Toju?
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
routerLink="/gallery"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Image Gallery
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Community -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold text-foreground mb-4 uppercase tracking-wider">Community</h4>
|
||||||
|
<ul class="space-y-3">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://git.azaaxin.com/myxelium/Toju"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/gitea.png"
|
||||||
|
alt=""
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
class="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
Source Code
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://github.com/Myxelium"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/github.png"
|
||||||
|
alt=""
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
class="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://buymeacoffee.com/myxelium"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/buymeacoffee.png"
|
||||||
|
alt=""
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
class="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
Support Us
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Values -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-semibold text-foreground mb-4 uppercase tracking-wider">Values</h4>
|
||||||
|
<ul class="space-y-3">
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
routerLink="/philosophy"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Our Philosophy
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li><span class="text-sm text-muted-foreground">100% Free Forever</span></li>
|
||||||
|
<li><span class="text-sm text-muted-foreground">Open Source</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-12 pt-8 border-t border-border/30 flex flex-col md:flex-row justify-between items-center gap-4">
|
||||||
|
<p class="text-xs text-muted-foreground">© {{ currentYear }} Myxelium. Toju is open-source software.</p>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a
|
||||||
|
href="https://git.azaaxin.com/myxelium/Toju"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
aria-label="View source code on Gitea"
|
||||||
|
class="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/gitea.png"
|
||||||
|
alt=""
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
class="w-5 h-5 object-contain"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://github.com/Myxelium"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
aria-label="View the project on GitHub"
|
||||||
|
class="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/github.png"
|
||||||
|
alt=""
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
class="w-5 h-5 object-contain"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
12
website/src/app/components/footer/footer.component.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-footer',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink],
|
||||||
|
templateUrl: './footer.component.html'
|
||||||
|
})
|
||||||
|
export class FooterComponent {
|
||||||
|
readonly currentYear = new Date().getFullYear();
|
||||||
|
}
|
||||||
184
website/src/app/components/header/header.component.html
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
<header
|
||||||
|
class="fixed top-0 left-0 right-0 z-50 transition-all duration-300"
|
||||||
|
[class]="scrolled() ? 'glass shadow-lg shadow-black/20' : 'bg-transparent'"
|
||||||
|
>
|
||||||
|
<nav class="container mx-auto px-6 py-4 flex items-center justify-between">
|
||||||
|
<!-- Logo -->
|
||||||
|
<a
|
||||||
|
routerLink="/"
|
||||||
|
aria-label="Toju home"
|
||||||
|
class="flex items-center group"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-1.5">
|
||||||
|
<img
|
||||||
|
src="/images/toju-logo-transparent.png"
|
||||||
|
alt="Toju"
|
||||||
|
class="h-9 w-auto object-contain drop-shadow-lg group-hover:opacity-90 transition-opacity"
|
||||||
|
/>
|
||||||
|
<span class="text-xl font-bold text-foreground">Toju</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="ml-2 text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-purple-500/20 text-purple-400 border border-purple-500/30 uppercase tracking-wider"
|
||||||
|
>
|
||||||
|
Beta
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Desktop nav -->
|
||||||
|
<div class="hidden md:flex items-center gap-8">
|
||||||
|
<a
|
||||||
|
routerLink="/"
|
||||||
|
routerLinkActive="text-primary"
|
||||||
|
[routerLinkActiveOptions]="{ exact: true }"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
routerLink="/what-is-toju"
|
||||||
|
routerLinkActive="text-primary"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
What is Toju?
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
routerLink="/downloads"
|
||||||
|
routerLinkActive="text-primary"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Downloads
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
routerLink="/philosophy"
|
||||||
|
routerLinkActive="text-primary"
|
||||||
|
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Our Philosophy
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right side -->
|
||||||
|
<div class="hidden md:flex items-center gap-4">
|
||||||
|
<a
|
||||||
|
href="https://buymeacoffee.com/myxelium"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-sm text-muted-foreground hover:text-yellow-400 transition-colors flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/buymeacoffee.png"
|
||||||
|
alt=""
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
class="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
Support Us
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://web.toju.app/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex items-center gap-2 px-5 py-2 rounded-lg bg-gradient-to-r from-purple-600 to-violet-600 text-white text-sm font-medium hover:from-purple-500 hover:to-violet-500 transition-all shadow-lg shadow-purple-500/25 hover:shadow-purple-500/40"
|
||||||
|
>
|
||||||
|
Use Web Version
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile hamburger -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="md:hidden text-foreground p-2"
|
||||||
|
(click)="mobileOpen.set(!mobileOpen())"
|
||||||
|
aria-label="Toggle menu"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
@if (mobileOpen()) {
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Mobile nav -->
|
||||||
|
@if (mobileOpen()) {
|
||||||
|
<div
|
||||||
|
class="md:hidden glass border-t border-border/30 px-6 py-4 space-y-4"
|
||||||
|
(click)="mobileOpen.set(false)"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
routerLink="/"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>Home</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
routerLink="/what-is-toju"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>What is Toju?</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
routerLink="/downloads"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>Downloads</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
routerLink="/philosophy"
|
||||||
|
class="block text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>Our Philosophy</a
|
||||||
|
>
|
||||||
|
<hr class="border-border/30" />
|
||||||
|
<a
|
||||||
|
href="https://buymeacoffee.com/myxelium"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-yellow-400 transition-colors"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/buymeacoffee.png"
|
||||||
|
alt=""
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
class="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
Support Us
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://web.toju.app/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex items-center gap-2 px-5 py-2 rounded-lg bg-gradient-to-r from-purple-600 to-violet-600 text-white text-sm font-medium"
|
||||||
|
>
|
||||||
|
Use Web Version
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</header>
|
||||||
29
website/src/app/components/header/header.component.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
HostListener,
|
||||||
|
PLATFORM_ID
|
||||||
|
} from '@angular/core';
|
||||||
|
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-header',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink, RouterLinkActive],
|
||||||
|
templateUrl: './header.component.html'
|
||||||
|
})
|
||||||
|
export class HeaderComponent {
|
||||||
|
readonly scrolled = signal(false);
|
||||||
|
readonly mobileOpen = signal(false);
|
||||||
|
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
|
@HostListener('window:scroll')
|
||||||
|
onScroll(): void {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
this.scrolled.set(window.scrollY > 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
206
website/src/app/components/particle-bg/particle-bg.component.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
PLATFORM_ID,
|
||||||
|
ViewChild,
|
||||||
|
inject
|
||||||
|
} from '@angular/core';
|
||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-particle-bg',
|
||||||
|
standalone: true,
|
||||||
|
host: {
|
||||||
|
class: 'block fixed inset-0 z-0 pointer-events-none'
|
||||||
|
},
|
||||||
|
template: '<canvas #canvas class="absolute inset-0 h-full w-full pointer-events-auto"></canvas>'
|
||||||
|
})
|
||||||
|
export class ParticleBgComponent implements OnInit, OnDestroy {
|
||||||
|
@ViewChild('canvas', { static: true }) private canvasRef?: ElementRef<HTMLCanvasElement>;
|
||||||
|
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
|
private context: CanvasRenderingContext2D | null = null;
|
||||||
|
private particles: Particle[] = [];
|
||||||
|
private mousePosition = {
|
||||||
|
pointerX: -1000,
|
||||||
|
pointerY: -1000
|
||||||
|
};
|
||||||
|
private animationId = 0;
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (!isPlatformBrowser(this.platformId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = this.canvasRef?.nativeElement;
|
||||||
|
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.context = context;
|
||||||
|
this.resize();
|
||||||
|
|
||||||
|
window.addEventListener('resize', this.resizeHandler);
|
||||||
|
window.addEventListener('mousemove', this.mouseMoveHandler);
|
||||||
|
|
||||||
|
this.initParticles();
|
||||||
|
this.animate();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (!isPlatformBrowser(this.platformId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelAnimationFrame(this.animationId);
|
||||||
|
window.removeEventListener('resize', this.resizeHandler);
|
||||||
|
window.removeEventListener('mousemove', this.mouseMoveHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly resizeHandler = () => this.resize();
|
||||||
|
private readonly mouseMoveHandler = (event: MouseEvent) => {
|
||||||
|
this.mousePosition.pointerX = event.clientX;
|
||||||
|
this.mousePosition.pointerY = event.clientY;
|
||||||
|
};
|
||||||
|
|
||||||
|
private resize(): void {
|
||||||
|
const canvas = this.canvasRef?.nativeElement;
|
||||||
|
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = window.innerWidth;
|
||||||
|
canvas.height = window.innerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
private initParticles(): void {
|
||||||
|
const particleCount = Math.min(
|
||||||
|
80,
|
||||||
|
Math.floor((window.innerWidth * window.innerHeight) / 15000)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.particles = [];
|
||||||
|
|
||||||
|
for (let particleIndex = 0; particleIndex < particleCount; particleIndex++) {
|
||||||
|
this.particles.push({
|
||||||
|
positionX: Math.random() * window.innerWidth,
|
||||||
|
positionY: Math.random() * window.innerHeight,
|
||||||
|
velocityX: (Math.random() - 0.5) * 0.4,
|
||||||
|
velocityY: (Math.random() - 0.5) * 0.4,
|
||||||
|
radius: Math.random() * 2 + 0.5,
|
||||||
|
opacity: Math.random() * 0.5 + 0.1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private animate(): void {
|
||||||
|
const canvas = this.canvasRef?.nativeElement;
|
||||||
|
const context = this.context;
|
||||||
|
|
||||||
|
if (!canvas || !context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
for (const particle of this.particles) {
|
||||||
|
const deltaX = particle.positionX - this.mousePosition.pointerX;
|
||||||
|
const deltaY = particle.positionY - this.mousePosition.pointerY;
|
||||||
|
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||||
|
|
||||||
|
if (distance < 150 && distance > 0) {
|
||||||
|
const force = (150 - distance) / 150;
|
||||||
|
|
||||||
|
particle.velocityX += (deltaX / distance) * force * 0.3;
|
||||||
|
particle.velocityY += (deltaY / distance) * force * 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
particle.velocityX *= 0.98;
|
||||||
|
particle.velocityY *= 0.98;
|
||||||
|
|
||||||
|
particle.positionX += particle.velocityX;
|
||||||
|
particle.positionY += particle.velocityY;
|
||||||
|
|
||||||
|
if (particle.positionX < 0) {
|
||||||
|
particle.positionX = canvas.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (particle.positionX > canvas.width) {
|
||||||
|
particle.positionX = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (particle.positionY < 0) {
|
||||||
|
particle.positionY = canvas.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (particle.positionY > canvas.height) {
|
||||||
|
particle.positionY = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.beginPath();
|
||||||
|
context.arc(particle.positionX, particle.positionY, particle.radius, 0, Math.PI * 2);
|
||||||
|
context.fillStyle = `rgba(139, 92, 246, ${particle.opacity})`;
|
||||||
|
context.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let particleIndex = 0; particleIndex < this.particles.length; particleIndex++) {
|
||||||
|
for (let connectionIndex = particleIndex + 1; connectionIndex < this.particles.length; connectionIndex++) {
|
||||||
|
const sourceParticle = this.particles[particleIndex];
|
||||||
|
const targetParticle = this.particles[connectionIndex];
|
||||||
|
const deltaX = sourceParticle.positionX - targetParticle.positionX;
|
||||||
|
const deltaY = sourceParticle.positionY - targetParticle.positionY;
|
||||||
|
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||||
|
|
||||||
|
if (distance < 120) {
|
||||||
|
const opacity = (1 - distance / 120) * 0.15;
|
||||||
|
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(sourceParticle.positionX, sourceParticle.positionY);
|
||||||
|
context.lineTo(targetParticle.positionX, targetParticle.positionY);
|
||||||
|
context.strokeStyle = `rgba(139, 92, 246, ${opacity})`;
|
||||||
|
context.lineWidth = 0.5;
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const particle of this.particles) {
|
||||||
|
const deltaX = particle.positionX - this.mousePosition.pointerX;
|
||||||
|
const deltaY = particle.positionY - this.mousePosition.pointerY;
|
||||||
|
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||||
|
|
||||||
|
if (distance < 200) {
|
||||||
|
const opacity = (1 - distance / 200) * 0.25;
|
||||||
|
|
||||||
|
context.beginPath();
|
||||||
|
context.moveTo(particle.positionX, particle.positionY);
|
||||||
|
context.lineTo(this.mousePosition.pointerX, this.mousePosition.pointerY);
|
||||||
|
context.strokeStyle = `rgba(139, 92, 246, ${opacity})`;
|
||||||
|
context.lineWidth = 0.7;
|
||||||
|
context.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.animationId = requestAnimationFrame(() => this.animate());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Particle {
|
||||||
|
positionX: number;
|
||||||
|
positionY: number;
|
||||||
|
velocityX: number;
|
||||||
|
velocityY: number;
|
||||||
|
radius: number;
|
||||||
|
opacity: number;
|
||||||
|
}
|
||||||
46
website/src/app/directives/parallax.directive.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
Directive,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
Input,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
PLATFORM_ID
|
||||||
|
} from '@angular/core';
|
||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[appParallax]',
|
||||||
|
standalone: true
|
||||||
|
})
|
||||||
|
export class ParallaxDirective implements OnInit, OnDestroy {
|
||||||
|
@Input() appParallax = 0.3;
|
||||||
|
|
||||||
|
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (!isPlatformBrowser(this.platformId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('scroll', this.scrollHandler, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
if (!isPlatformBrowser(this.platformId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removeEventListener('scroll', this.scrollHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly scrollHandler = () => this.onScroll();
|
||||||
|
|
||||||
|
private onScroll(): void {
|
||||||
|
const scrolled = window.scrollY;
|
||||||
|
const rate = scrolled * this.appParallax;
|
||||||
|
|
||||||
|
this.elementRef.nativeElement.style.transform = `translateY(${rate}px)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
269
website/src/app/pages/downloads/downloads.component.html
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
<div class="min-h-screen pt-32 pb-20">
|
||||||
|
<section class="container mx-auto px-6 mb-16">
|
||||||
|
<div class="max-w-3xl mx-auto text-center">
|
||||||
|
<h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">Download <span class="gradient-text">Toju</span></h1>
|
||||||
|
<p class="text-lg text-muted-foreground leading-relaxed">
|
||||||
|
Available for Windows, Linux, and in your browser. Always free, always the full experience.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Recommended Download -->
|
||||||
|
@if (latestRelease()) {
|
||||||
|
<section class="container mx-auto px-6 mb-16">
|
||||||
|
<div class="max-w-2xl mx-auto section-fade">
|
||||||
|
<div
|
||||||
|
class="rounded-2xl border border-purple-500/20 bg-gradient-to-br from-purple-950/20 to-violet-950/20 backdrop-blur-sm p-8 md:p-10 text-center"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-sm font-medium mb-4"
|
||||||
|
>
|
||||||
|
Recommended for you
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl font-bold text-foreground mb-2">Toju for {{ detectedOS().name }}</h2>
|
||||||
|
<p class="text-muted-foreground mb-6">Version {{ latestRelease()!.tag_name }}</p>
|
||||||
|
|
||||||
|
@if (recommendedUrl()) {
|
||||||
|
<a
|
||||||
|
[href]="recommendedUrl()"
|
||||||
|
class="inline-flex items-center gap-3 px-8 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold text-lg hover:from-purple-500 hover:to-violet-500 transition-all shadow-2xl shadow-purple-500/25 hover:shadow-purple-500/40"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Download for {{ detectedOS().name }}
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
<p class="text-xs text-muted-foreground/60 mt-4">
|
||||||
|
Or
|
||||||
|
<a
|
||||||
|
href="https://web.toju.app/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="underline hover:text-muted-foreground transition-colors"
|
||||||
|
>
|
||||||
|
use the web version
|
||||||
|
</a>
|
||||||
|
- no download required.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<app-ad-slot />
|
||||||
|
|
||||||
|
<!-- All Downloads for Latest Release -->
|
||||||
|
@if (latestRelease(); as release) {
|
||||||
|
<section class="container mx-auto px-6 mb-20">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<h2 class="text-2xl font-bold text-foreground mb-8 section-fade">
|
||||||
|
All platforms <span class="text-muted-foreground font-normal text-lg">- {{ release.tag_name }}</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="grid gap-3 section-fade">
|
||||||
|
@for (asset of release.assets; track asset.name) {
|
||||||
|
@if (!isMetaFile(asset.name)) {
|
||||||
|
<a
|
||||||
|
[href]="asset.browser_download_url"
|
||||||
|
class="group flex items-center justify-between gap-4 rounded-xl border border-border/30 bg-card/30 backdrop-blur-sm p-5 hover:border-purple-500/30 hover:bg-card/50 transition-all"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-secondary flex items-center justify-center text-lg">
|
||||||
|
@if (getOsIcon(asset.name)) {
|
||||||
|
<img
|
||||||
|
[src]="getOsIcon(asset.name)"
|
||||||
|
[alt]="releaseService.getAssetOS(asset.name) + ' icon'"
|
||||||
|
width="32"
|
||||||
|
height="32"
|
||||||
|
class="w-8 h-8 object-contain invert"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-foreground group-hover:text-purple-400 transition-colors">{{ asset.name }}</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{{ releaseService.getAssetOS(asset.name) }} · {{ releaseService.formatBytes(asset.size) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-muted-foreground group-hover:text-purple-400 transition-colors"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Historical Releases -->
|
||||||
|
@if (releases().length > 1) {
|
||||||
|
<section class="container mx-auto px-6 mb-20">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<h2 class="text-2xl font-bold text-foreground mb-8 section-fade">Previous Releases</h2>
|
||||||
|
|
||||||
|
<div class="space-y-4 section-fade">
|
||||||
|
@for (release of releases().slice(1); track release.tag_name) {
|
||||||
|
<details class="group rounded-xl border border-border/30 bg-card/30 backdrop-blur-sm overflow-hidden">
|
||||||
|
<summary class="flex items-center justify-between gap-4 p-5 cursor-pointer hover:bg-card/50 transition-colors list-none">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-secondary flex items-center justify-center">
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 text-muted-foreground"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-foreground">{{ release.name || release.tag_name }}</p>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ formatDate(release.published_at) }} · {{ release.assets.length }} files</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 text-muted-foreground group-open:rotate-180 transition-transform"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
<div class="px-5 pb-5 space-y-2">
|
||||||
|
@if (release.body) {
|
||||||
|
<div class="text-sm text-muted-foreground mb-4 whitespace-pre-line border-b border-border/20 pb-4">{{ release.body }}</div>
|
||||||
|
}
|
||||||
|
@for (asset of release.assets; track asset.name) {
|
||||||
|
@if (!isMetaFile(asset.name) && !asset.name.toLowerCase().includes('server')) {
|
||||||
|
<a
|
||||||
|
[href]="asset.browser_download_url"
|
||||||
|
class="group/item flex items-center justify-between gap-4 rounded-lg border border-border/20 bg-background/50 p-3 hover:border-purple-500/30 transition-all"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
@if (getOsIcon(asset.name)) {
|
||||||
|
<img
|
||||||
|
[src]="getOsIcon(asset.name)"
|
||||||
|
[alt]="releaseService.getAssetOS(asset.name) + ' icon'"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
class="w-4 h-4 object-contain mr-1 invert"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium text-foreground group-hover/item:text-purple-400 transition-colors">{{ asset.name }}</p>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ releaseService.formatBytes(asset.size) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 text-muted-foreground"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="container mx-auto px-6 text-center py-20">
|
||||||
|
<div class="inline-flex items-center gap-3 text-muted-foreground">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 animate-spin"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
class="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="4"
|
||||||
|
></circle>
|
||||||
|
<path
|
||||||
|
class="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
Fetching releases...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- RSS Feed link -->
|
||||||
|
<section class="container mx-auto px-6">
|
||||||
|
<div class="max-w-4xl mx-auto text-center section-fade">
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 inline-block mr-1 -mt-0.5"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M6.18 15.64a2.18 2.18 0 012.18 2.18C8.36 19 7.38 20 6.18 20 5 20 4 19 4 17.82a2.18 2.18 0 012.18-2.18M4 4.44A15.56 15.56 0 0119.56 20h-2.83A12.73 12.73 0 004 7.27V4.44m0 5.66a9.9 9.9 0 019.9 9.9h-2.83A7.07 7.07 0 004 12.93V10.1z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Stay updated with our
|
||||||
|
<a
|
||||||
|
href="https://git.azaaxin.com/myxelium/Toju/releases.rss"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="underline hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
RSS feed
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
103
website/src/app/pages/downloads/downloads.component.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
AfterViewInit,
|
||||||
|
OnDestroy,
|
||||||
|
inject,
|
||||||
|
PLATFORM_ID,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
import {
|
||||||
|
ReleaseService,
|
||||||
|
Release,
|
||||||
|
DetectedOS
|
||||||
|
} from '../../services/release.service';
|
||||||
|
import { SeoService } from '../../services/seo.service';
|
||||||
|
import { ScrollAnimationService } from '../../services/scroll-animation.service';
|
||||||
|
import { AdSlotComponent } from '../../components/ad-slot/ad-slot.component';
|
||||||
|
import { getOsIconPath } from './os-icon.util';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-downloads',
|
||||||
|
standalone: true,
|
||||||
|
imports: [AdSlotComponent],
|
||||||
|
templateUrl: './downloads.component.html'
|
||||||
|
})
|
||||||
|
export class DownloadsComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
readonly releaseService = inject(ReleaseService);
|
||||||
|
readonly releases = signal<Release[]>([]);
|
||||||
|
readonly latestRelease = signal<Release | null>(null);
|
||||||
|
readonly detectedOS = signal<DetectedOS>({
|
||||||
|
name: 'Linux',
|
||||||
|
icon: '🐧',
|
||||||
|
filePattern: /\.AppImage$/i,
|
||||||
|
ymlFile: 'latest-linux.yml'
|
||||||
|
});
|
||||||
|
readonly recommendedUrl = signal<string | null>(null);
|
||||||
|
readonly loading = signal(true);
|
||||||
|
|
||||||
|
private readonly seoService = inject(SeoService);
|
||||||
|
private readonly scrollAnimation = inject(ScrollAnimationService);
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.seoService.update({
|
||||||
|
title: 'Download Toju',
|
||||||
|
description: 'Download Toju for Windows, Linux, or use the web version. Free peer-to-peer voice chat, screen sharing, and file transfers.',
|
||||||
|
url: 'https://toju.app/downloads'
|
||||||
|
});
|
||||||
|
|
||||||
|
const os = this.releaseService.detectOS();
|
||||||
|
|
||||||
|
this.detectedOS.set(os);
|
||||||
|
|
||||||
|
this.releaseService.fetchReleases().then((releases) => {
|
||||||
|
this.releases.set(releases);
|
||||||
|
|
||||||
|
if (releases.length > 0) {
|
||||||
|
const latestRelease = releases[0];
|
||||||
|
const recommendedAsset = latestRelease.assets.find(
|
||||||
|
(releaseAsset) => os.filePattern.test(releaseAsset.name) && !releaseAsset.name.toLowerCase().includes('server')
|
||||||
|
);
|
||||||
|
|
||||||
|
this.latestRelease.set(latestRelease);
|
||||||
|
this.recommendedUrl.set(recommendedAsset?.browser_download_url ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading.set(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
setTimeout(() => this.scrollAnimation.init(), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.scrollAnimation.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
isMetaFile(name: string): boolean {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
|
||||||
|
return lower.endsWith('.yml') || lower.endsWith('.yaml') || lower.endsWith('.blockmap') || lower.endsWith('.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
getOsIcon(name: string, size = 64): string {
|
||||||
|
return getOsIconPath(name, size);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(dateStr: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
website/src/app/pages/downloads/os-icon.util.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
const WINDOWS_SUFFIXES = ['.exe', '.msi'];
|
||||||
|
const WINDOWS_HINTS = ['win', 'windows'];
|
||||||
|
const MAC_SUFFIXES = ['.dmg', '.pkg'];
|
||||||
|
const MAC_HINTS = [
|
||||||
|
'mac',
|
||||||
|
'macos',
|
||||||
|
'osx',
|
||||||
|
'darwin'
|
||||||
|
];
|
||||||
|
const LINUX_SUFFIXES = [
|
||||||
|
'.appimage',
|
||||||
|
'.deb',
|
||||||
|
'.rpm'
|
||||||
|
];
|
||||||
|
const LINUX_HINTS = [
|
||||||
|
'linux',
|
||||||
|
'appimage',
|
||||||
|
'.deb',
|
||||||
|
'.rpm'
|
||||||
|
];
|
||||||
|
const ARCHIVE_SUFFIXES = [
|
||||||
|
'.zip',
|
||||||
|
'.tar',
|
||||||
|
'.tar.gz',
|
||||||
|
'.tgz',
|
||||||
|
'.tar.xz',
|
||||||
|
'.7z',
|
||||||
|
'.rar'
|
||||||
|
];
|
||||||
|
const ARCHIVE_HINTS = [
|
||||||
|
'archive',
|
||||||
|
'.zip',
|
||||||
|
'.tar',
|
||||||
|
'.7z',
|
||||||
|
'.rar'
|
||||||
|
];
|
||||||
|
|
||||||
|
function getSizedIconPath(folder: string, size: number): string {
|
||||||
|
return `/images/${folder}/${size}x${size}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function includesAny(value: string, hints: string[]): boolean {
|
||||||
|
const tokens = value.split(/[^a-z0-9]+/).filter(Boolean);
|
||||||
|
|
||||||
|
return hints.some((hint) => tokens.includes(hint));
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesIconPattern(value: string, suffixes: string[], hints: string[] = []): boolean {
|
||||||
|
return suffixes.some((suffix) => value.endsWith(suffix)) || includesAny(value, hints);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOsIconPath(nameOrOs: string, size = 64): string {
|
||||||
|
const normalized = nameOrOs.trim().toLowerCase();
|
||||||
|
|
||||||
|
if (matchesIconPattern(normalized, WINDOWS_SUFFIXES, WINDOWS_HINTS))
|
||||||
|
return getSizedIconPath('windows', size);
|
||||||
|
|
||||||
|
if (matchesIconPattern(normalized, MAC_SUFFIXES, MAC_HINTS))
|
||||||
|
return getSizedIconPath('macos', size);
|
||||||
|
|
||||||
|
if (matchesIconPattern(normalized, LINUX_SUFFIXES, LINUX_HINTS))
|
||||||
|
return getSizedIconPath('linux', size);
|
||||||
|
|
||||||
|
if (matchesIconPattern(normalized, ARCHIVE_SUFFIXES, ARCHIVE_HINTS))
|
||||||
|
return '/images/misc/zip.png';
|
||||||
|
|
||||||
|
return '/images/misc/file.png';
|
||||||
|
}
|
||||||
95
website/src/app/pages/gallery/gallery.component.html
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<div class="min-h-screen pt-32 pb-20">
|
||||||
|
<section class="container mx-auto px-6 mb-16">
|
||||||
|
<div class="max-w-3xl mx-auto text-center section-fade">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-sm font-medium mb-6"
|
||||||
|
>
|
||||||
|
Image Gallery
|
||||||
|
</div>
|
||||||
|
<h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">A closer look at <span class="gradient-text">Toju</span></h1>
|
||||||
|
<p class="text-lg text-muted-foreground leading-relaxed">
|
||||||
|
Explore screenshots of the app experience, from voice chat and media sharing to servers, rooms, and full-screen collaboration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="container mx-auto px-6 mb-20">
|
||||||
|
<div class="max-w-6xl mx-auto section-fade">
|
||||||
|
<div class="relative overflow-hidden rounded-3xl border border-border/30 bg-card/30 backdrop-blur-sm">
|
||||||
|
<div class="relative aspect-[16/9]">
|
||||||
|
<img
|
||||||
|
ngSrc="/images/screenshots/screenshot_main.png"
|
||||||
|
alt="Toju main application screenshot"
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
sizes="(min-width: 1536px) 75vw, (min-width: 1280px) 90vw, 100vw"
|
||||||
|
class="object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="absolute inset-x-0 bottom-0 bg-gradient-to-t from-background via-background/80 to-transparent p-6 md:p-8">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-purple-400 mb-2">Featured</p>
|
||||||
|
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-2">The full Toju workspace</h2>
|
||||||
|
<p class="max-w-2xl text-sm md:text-base text-muted-foreground">
|
||||||
|
See the main interface where rooms, messages, presence, and media all come together in one focused layout.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-ad-slot />
|
||||||
|
|
||||||
|
<section class="container mx-auto px-6 mb-20">
|
||||||
|
<div class="max-w-6xl mx-auto">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||||
|
@for (item of galleryItems; track item.src) {
|
||||||
|
<a
|
||||||
|
[href]="item.src"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="section-fade group overflow-hidden rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm hover:border-purple-500/30 hover:bg-card/50 transition-all"
|
||||||
|
>
|
||||||
|
<div class="relative aspect-video overflow-hidden">
|
||||||
|
<img
|
||||||
|
[ngSrc]="item.src"
|
||||||
|
[alt]="item.title"
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 768px) 100vw, (max-width: 1280px) 50vw, 33vw"
|
||||||
|
class="object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="p-5">
|
||||||
|
<h3 class="text-lg font-semibold text-foreground mb-2">{{ item.title }}</h3>
|
||||||
|
<p class="text-sm text-muted-foreground leading-relaxed">{{ item.description }}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="container mx-auto px-6">
|
||||||
|
<div
|
||||||
|
class="max-w-4xl mx-auto section-fade rounded-3xl border border-purple-500/20 bg-gradient-to-br from-purple-950/20 to-violet-950/20 p-8 md:p-10 text-center"
|
||||||
|
>
|
||||||
|
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-4">Want to see it in action?</h2>
|
||||||
|
<p class="text-muted-foreground leading-relaxed mb-6">Download Toju or jump into the browser experience and explore the interface yourself.</p>
|
||||||
|
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
|
<a
|
||||||
|
routerLink="/downloads"
|
||||||
|
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold hover:from-purple-500 hover:to-violet-500 transition-all shadow-lg shadow-purple-500/25"
|
||||||
|
>
|
||||||
|
Go to downloads
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://web.toju.app/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium hover:border-purple-500/30 transition-all"
|
||||||
|
>
|
||||||
|
Open web version
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
91
website/src/app/pages/gallery/gallery.component.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
AfterViewInit,
|
||||||
|
Component,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
PLATFORM_ID,
|
||||||
|
inject
|
||||||
|
} from '@angular/core';
|
||||||
|
import { isPlatformBrowser, NgOptimizedImage } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { AdSlotComponent } from '../../components/ad-slot/ad-slot.component';
|
||||||
|
import { ScrollAnimationService } from '../../services/scroll-animation.service';
|
||||||
|
import { SeoService } from '../../services/seo.service';
|
||||||
|
|
||||||
|
interface GalleryItem {
|
||||||
|
src: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-gallery',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
NgOptimizedImage,
|
||||||
|
RouterLink,
|
||||||
|
AdSlotComponent
|
||||||
|
],
|
||||||
|
templateUrl: './gallery.component.html'
|
||||||
|
})
|
||||||
|
export class GalleryComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
readonly galleryItems: GalleryItem[] = [
|
||||||
|
{
|
||||||
|
src: '/images/screenshots/screenshot_main.png',
|
||||||
|
title: 'Main chat view',
|
||||||
|
description: 'The core Toju experience with channels, messages, and direct communication tools.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/images/screenshots/screenshare_gaming.png',
|
||||||
|
title: 'Gaming screen share',
|
||||||
|
description: 'Share gameplay, guides, and live moments with smooth full-resolution screen sharing.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/images/screenshots/serverViewScreen.png',
|
||||||
|
title: 'Server overview',
|
||||||
|
description: 'Navigate servers and rooms with a layout designed for clarity and speed.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/images/screenshots/music.png',
|
||||||
|
title: 'Music and voice',
|
||||||
|
description: 'Stay in sync with voice and media features in a focused, low-friction interface.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/images/screenshots/videos.png',
|
||||||
|
title: 'Video sharing',
|
||||||
|
description: 'Preview and share visual content directly with your friends and communities.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/images/screenshots/filedownload.png',
|
||||||
|
title: 'File transfers',
|
||||||
|
description: 'Move files quickly without artificial size limits or unnecessary hoops.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/images/screenshots/gif.png',
|
||||||
|
title: 'Rich media chat',
|
||||||
|
description: 'Conversations stay lively with visual media support built right in.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
private readonly seoService = inject(SeoService);
|
||||||
|
private readonly scrollAnimation = inject(ScrollAnimationService);
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.seoService.update({
|
||||||
|
title: 'Toju Image Gallery',
|
||||||
|
description: 'Browse screenshots of Toju and explore the interface for chat, file sharing, voice, and screen sharing.',
|
||||||
|
url: 'https://toju.app/gallery'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
setTimeout(() => this.scrollAnimation.init(), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.scrollAnimation.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
542
website/src/app/pages/home/home.component.html
Normal file
@@ -0,0 +1,542 @@
|
|||||||
|
<!-- Hero -->
|
||||||
|
<section class="relative min-h-screen flex items-center justify-center overflow-hidden">
|
||||||
|
<!-- Gradient orbs -->
|
||||||
|
<div
|
||||||
|
[appParallax]="0.15"
|
||||||
|
class="absolute top-1/4 -left-32 w-96 h-96 bg-purple-600/20 rounded-full blur-[128px] animate-float"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
[appParallax]="0.25"
|
||||||
|
class="absolute bottom-1/4 -right-32 w-80 h-80 bg-violet-500/15 rounded-full blur-[100px] animate-float"
|
||||||
|
style="animation-delay: -3s"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<div class="relative z-10 container mx-auto px-6 text-center pt-32 pb-20">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 px-4 py-1.5 rounded-full border border-purple-500/30 bg-purple-500/10 text-purple-400 text-sm font-medium mb-8 animate-fade-in"
|
||||||
|
>
|
||||||
|
<span class="w-2 h-2 rounded-full bg-purple-400 animate-pulse"></span>
|
||||||
|
Currently in Beta - Free & Open Source
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-5xl md:text-7xl lg:text-8xl font-extrabold tracking-tight mb-6 animate-fade-in-up">
|
||||||
|
<span class="text-foreground">Talk freely.</span><br />
|
||||||
|
<span class="gradient-text">Own your voice.</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="text-lg md:text-xl text-muted-foreground max-w-2xl mx-auto mb-10 leading-relaxed animate-fade-in-up"
|
||||||
|
style="animation-delay: 0.2s"
|
||||||
|
>
|
||||||
|
Crystal-clear voice calls, unlimited screen sharing, and file transfers with no size limits. Peer-to-peer. Private. Completely free.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- CTA Buttons -->
|
||||||
|
<div
|
||||||
|
class="flex flex-col sm:flex-row items-center justify-center gap-4 mb-6 animate-fade-in-up"
|
||||||
|
style="animation-delay: 0.4s"
|
||||||
|
>
|
||||||
|
@if (downloadUrl()) {
|
||||||
|
<a
|
||||||
|
[href]="downloadUrl()"
|
||||||
|
class="group inline-flex items-center gap-3 px-8 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold text-lg hover:from-purple-500 hover:to-violet-500 transition-all shadow-2xl shadow-purple-500/25 hover:shadow-purple-500/40 animate-glow-pulse"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Download for {{ detectedOS().name }}
|
||||||
|
<span class="text-sm opacity-75">{{ detectedOS().icon }}</span>
|
||||||
|
</a>
|
||||||
|
} @else {
|
||||||
|
<a
|
||||||
|
routerLink="/downloads"
|
||||||
|
class="group inline-flex items-center gap-3 px-8 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold text-lg hover:from-purple-500 hover:to-violet-500 transition-all shadow-2xl shadow-purple-500/25 hover:shadow-purple-500/40"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Download Toju
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="https://web.toju.app/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex items-center gap-2 px-8 py-4 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium text-lg hover:bg-card hover:border-purple-500/30 transition-all backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
Open in Browser
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 opacity-60"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (latestVersion()) {
|
||||||
|
<p
|
||||||
|
class="text-xs text-muted-foreground/60 animate-fade-in"
|
||||||
|
style="animation-delay: 0.6s"
|
||||||
|
>
|
||||||
|
Version {{ latestVersion() }} ·
|
||||||
|
<a
|
||||||
|
routerLink="/downloads"
|
||||||
|
class="underline hover:text-muted-foreground transition-colors"
|
||||||
|
>All platforms</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Scroll indicator -->
|
||||||
|
<div class="absolute bottom-8 left-1/2 -translate-x-1/2 animate-bounce">
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 text-muted-foreground/40"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-ad-slot />
|
||||||
|
|
||||||
|
<!-- Features Grid -->
|
||||||
|
<section class="relative py-32">
|
||||||
|
<div class="container mx-auto px-6">
|
||||||
|
<div class="text-center mb-20 section-fade">
|
||||||
|
<h2 class="text-3xl md:text-5xl font-bold text-foreground mb-4">
|
||||||
|
Everything you need,<br />
|
||||||
|
<span class="gradient-text">nothing you don't.</span>
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted-foreground text-lg max-w-xl mx-auto">No bloat. No paywalls. Just the tools to connect with the people who matter.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- Voice Calls -->
|
||||||
|
<div
|
||||||
|
class="section-fade group relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 hover:border-purple-500/30 hover:bg-card/50 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-purple-500/10 flex items-center justify-center mb-6 group-hover:bg-purple-500/20 transition-colors">
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 text-purple-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-foreground mb-3">HD Voice Calls</h3>
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
Crystal-clear audio with built-in noise reduction. Hear every word, not the background. No quality compromises, ever.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Screen Sharing -->
|
||||||
|
<div
|
||||||
|
class="section-fade group relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 hover:border-purple-500/30 hover:bg-card/50 transition-all duration-300"
|
||||||
|
style="transition-delay: 0.1s"
|
||||||
|
>
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-violet-500/10 flex items-center justify-center mb-6 group-hover:bg-violet-500/20 transition-colors">
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 text-violet-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-foreground mb-3">Screen Sharing</h3>
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
Share your screen at full resolution. No time limits, no quality downgrades. Perfect for pair programming, presentations, or showing your
|
||||||
|
epic gameplay.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File Sharing -->
|
||||||
|
<div
|
||||||
|
class="section-fade group relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 hover:border-purple-500/30 hover:bg-card/50 transition-all duration-300"
|
||||||
|
style="transition-delay: 0.2s"
|
||||||
|
>
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-pink-500/10 flex items-center justify-center mb-6 group-hover:bg-pink-500/20 transition-colors">
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 text-pink-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-foreground mb-3">Unlimited File Sharing</h3>
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
Send files of any size directly to your friends. No upload limits, no compression. Your files go straight from you to them.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Privacy -->
|
||||||
|
<div
|
||||||
|
class="section-fade group relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 hover:border-purple-500/30 hover:bg-card/50 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-emerald-500/10 flex items-center justify-center mb-6 group-hover:bg-emerald-500/20 transition-colors">
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 text-emerald-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-foreground mb-3">True Privacy</h3>
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
Peer-to-peer means your data goes directly between you and your friends. No servers storing your conversations. Your business is your
|
||||||
|
business.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Open Source -->
|
||||||
|
<div
|
||||||
|
class="section-fade group relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 hover:border-purple-500/30 hover:bg-card/50 transition-all duration-300"
|
||||||
|
style="transition-delay: 0.1s"
|
||||||
|
>
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-blue-500/10 flex items-center justify-center mb-6 group-hover:bg-blue-500/20 transition-colors">
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 text-blue-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-foreground mb-3">Open Source</h3>
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
Every line of code is public. Audit it, modify it, host your own signal server. Full transparency - nothing is hidden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Free -->
|
||||||
|
<div
|
||||||
|
class="section-fade group relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 hover:border-purple-500/30 hover:bg-card/50 transition-all duration-300"
|
||||||
|
style="transition-delay: 0.2s"
|
||||||
|
>
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-amber-500/10 flex items-center justify-center mb-6 group-hover:bg-amber-500/20 transition-colors">
|
||||||
|
<svg
|
||||||
|
class="w-6 h-6 text-amber-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-foreground mb-3">Completely Free</h3>
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
No premium tiers. No paywalls. No "starter plans". Every feature is available to everyone, always. Made with love, not profit margins.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-ad-slot />
|
||||||
|
|
||||||
|
<!-- Gaming Section -->
|
||||||
|
<section class="relative py-32">
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-b from-transparent via-purple-950/10 to-transparent"></div>
|
||||||
|
<div class="relative container mx-auto px-6">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
|
||||||
|
<div class="section-fade">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-sm font-medium mb-6"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Built for Gamers
|
||||||
|
</div>
|
||||||
|
<h2 class="text-3xl md:text-5xl font-bold text-foreground mb-6">
|
||||||
|
Your perfect<br />
|
||||||
|
<span class="gradient-text">gaming companion.</span>
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg text-muted-foreground leading-relaxed mb-8">
|
||||||
|
Ultra-low latency voice chat that doesn't eat your bandwidth. Share your screen without frame drops. Send clips and files instantly. All
|
||||||
|
while keeping your CPU free for what matters - winning.
|
||||||
|
</p>
|
||||||
|
<ul class="space-y-4">
|
||||||
|
<li class="flex items-center gap-3 text-muted-foreground">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-purple-400 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Low-latency peer-to-peer voice - no relay servers in the way</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3 text-muted-foreground">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-purple-400 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>AI-powered noise suppression - keyboard clatter stays out</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3 text-muted-foreground">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-purple-400 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Full-resolution screen sharing at high FPS</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3 text-muted-foreground">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-purple-400 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Send replays and screenshots with no file size limit</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-fade relative">
|
||||||
|
<div class="relative rounded-2xl overflow-hidden border border-border/30 bg-card/30 backdrop-blur-sm aspect-video">
|
||||||
|
<img
|
||||||
|
ngSrc="/images/screenshots/screenshare_gaming.png"
|
||||||
|
fill
|
||||||
|
priority
|
||||||
|
alt="Toju gaming screen sharing preview"
|
||||||
|
class="object-cover"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-background/80 via-transparent to-transparent"></div>
|
||||||
|
<div class="absolute bottom-4 left-4 text-sm text-muted-foreground/60">Game on. No limits.</div>
|
||||||
|
</div>
|
||||||
|
<!-- Glow effect -->
|
||||||
|
<div class="absolute -inset-4 bg-purple-600/5 rounded-3xl blur-2xl -z-10"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-ad-slot />
|
||||||
|
|
||||||
|
<!-- Self-hostable Section -->
|
||||||
|
<section class="relative py-32">
|
||||||
|
<div class="container mx-auto px-6">
|
||||||
|
<div class="section-fade max-w-3xl mx-auto text-center">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-sm font-medium mb-6"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Self-Hostable
|
||||||
|
</div>
|
||||||
|
<h2 class="text-3xl md:text-5xl font-bold text-foreground mb-6">
|
||||||
|
Your infrastructure,<br />
|
||||||
|
<span class="gradient-text">your rules.</span>
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg text-muted-foreground leading-relaxed mb-8">
|
||||||
|
Toju uses a lightweight coordination server to help peers find each other - that's it. Your actual conversations never touch a server. Want
|
||||||
|
even more control? Run your own coordination server in minutes. Full independence, zero compromises.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
|
<a
|
||||||
|
routerLink="/what-is-toju"
|
||||||
|
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium hover:border-purple-500/30 transition-all"
|
||||||
|
>
|
||||||
|
Learn how it works
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://git.azaaxin.com/myxelium/Toju"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex items-center gap-2 px-6 py-3 text-muted-foreground hover:text-foreground transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/gitea.png"
|
||||||
|
alt=""
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
class="w-4 h-4 object-contain"
|
||||||
|
/>
|
||||||
|
View source code
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA Banner -->
|
||||||
|
<section class="relative py-24">
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-r from-purple-950/20 via-violet-950/30 to-purple-950/20"></div>
|
||||||
|
<div class="relative container mx-auto px-6 text-center section-fade">
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold text-foreground mb-4">Ready to take back your conversations?</h2>
|
||||||
|
<p class="text-muted-foreground text-lg mb-8 max-w-lg mx-auto">Join thousands choosing privacy, freedom, and real connection.</p>
|
||||||
|
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
|
@if (downloadUrl()) {
|
||||||
|
<a
|
||||||
|
[href]="downloadUrl()"
|
||||||
|
class="inline-flex items-center gap-2 px-8 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold hover:from-purple-500 hover:to-violet-500 transition-all shadow-2xl shadow-purple-500/25"
|
||||||
|
>
|
||||||
|
Download for {{ detectedOS().name }}
|
||||||
|
</a>
|
||||||
|
} @else {
|
||||||
|
<a
|
||||||
|
routerLink="/downloads"
|
||||||
|
class="inline-flex items-center gap-2 px-8 py-4 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold hover:from-purple-500 hover:to-violet-500 transition-all shadow-2xl shadow-purple-500/25"
|
||||||
|
>
|
||||||
|
Download Toju
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
<a
|
||||||
|
href="https://web.toju.app/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex items-center gap-2 px-8 py-4 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium hover:border-purple-500/30 transition-all"
|
||||||
|
>
|
||||||
|
Try in Browser
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
77
website/src/app/pages/home/home.component.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
OnDestroy,
|
||||||
|
AfterViewInit,
|
||||||
|
inject,
|
||||||
|
PLATFORM_ID,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { isPlatformBrowser, NgOptimizedImage } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { ReleaseService, DetectedOS } from '../../services/release.service';
|
||||||
|
import { SeoService } from '../../services/seo.service';
|
||||||
|
import { ScrollAnimationService } from '../../services/scroll-animation.service';
|
||||||
|
import { AdSlotComponent } from '../../components/ad-slot/ad-slot.component';
|
||||||
|
import { ParallaxDirective } from '../../directives/parallax.directive';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-home',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
NgOptimizedImage,
|
||||||
|
RouterLink,
|
||||||
|
AdSlotComponent,
|
||||||
|
ParallaxDirective
|
||||||
|
],
|
||||||
|
templateUrl: './home.component.html'
|
||||||
|
})
|
||||||
|
export class HomeComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
readonly detectedOS = signal<DetectedOS>({
|
||||||
|
name: 'Linux',
|
||||||
|
icon: '🐧',
|
||||||
|
filePattern: /\.AppImage$/i,
|
||||||
|
ymlFile: 'latest-linux.yml'
|
||||||
|
});
|
||||||
|
readonly downloadUrl = signal<string | null>(null);
|
||||||
|
readonly latestVersion = signal<string | null>(null);
|
||||||
|
|
||||||
|
private readonly releaseService = inject(ReleaseService);
|
||||||
|
private readonly seoService = inject(SeoService);
|
||||||
|
private readonly scrollAnimation = inject(ScrollAnimationService);
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.seoService.update({
|
||||||
|
title: 'Free Peer-to-Peer Voice, Video & Chat',
|
||||||
|
description:
|
||||||
|
'Toju is a free, open-source, peer-to-peer communication app. Crystal-clear voice calls, unlimited screen sharing, '
|
||||||
|
+ 'no file size limits, complete privacy.',
|
||||||
|
url: 'https://toju.app/'
|
||||||
|
});
|
||||||
|
|
||||||
|
const os = this.releaseService.detectOS();
|
||||||
|
|
||||||
|
this.detectedOS.set(os);
|
||||||
|
|
||||||
|
this.releaseService.getLatestRelease().then((release) => {
|
||||||
|
if (release) {
|
||||||
|
this.latestVersion.set(release.tag_name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.releaseService.getDownloadUrl(os).then((url) => {
|
||||||
|
this.downloadUrl.set(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
setTimeout(() => this.scrollAnimation.init(), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.scrollAnimation.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
214
website/src/app/pages/philosophy/philosophy.component.html
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
<div class="min-h-screen pt-32 pb-20">
|
||||||
|
<!-- Hero -->
|
||||||
|
<section class="container mx-auto px-6 mb-24">
|
||||||
|
<div class="max-w-3xl mx-auto text-center">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-sm font-medium mb-6"
|
||||||
|
>
|
||||||
|
Our Manifesto
|
||||||
|
</div>
|
||||||
|
<h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">Why we <span class="gradient-text">build</span> Toju</h1>
|
||||||
|
<p class="text-lg text-muted-foreground leading-relaxed">A letter from the people behind the project.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-ad-slot />
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<section class="container mx-auto px-6 mb-24">
|
||||||
|
<article class="max-w-3xl mx-auto prose prose-invert prose-lg">
|
||||||
|
<!-- Ownership -->
|
||||||
|
<div class="section-fade mb-16">
|
||||||
|
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6 !mt-0">We Lost Something Important</h2>
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
Over the past two decades, something fundamental shifted. Our conversations, our memories, our connections - they stopped belonging to us.
|
||||||
|
They live on servers we don't control, inside apps that treat our personal lives as data to be harvested, analyzed, and sold to the highest
|
||||||
|
bidder.
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
We gave up ownership of our digital lives so gradually that most of us didn't even notice. A "free" app here, a convenient service there -
|
||||||
|
each one taking a little more of our privacy in exchange for convenience. Toju exists because we believe it's time to take it back.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No predatory pricing -->
|
||||||
|
<div class="section-fade mb-16">
|
||||||
|
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">No Paywalls. No Premium Tiers. Ever.</h2>
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
You know the playbook: launch a free product, build a user base, then start locking features behind subscription tiers. Can't share your
|
||||||
|
screen at more than 720p unless you upgrade. File size limited to 8 MB. Want noise suppression? That's a premium feature now.
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
We refuse to play that game. <strong class="text-foreground">Every feature in Toju is available to every user, always.</strong>
|
||||||
|
There is no "Toju Nitro," no "Pro plan," no artificial limitations designed to push you toward your wallet. Communication is a human need,
|
||||||
|
not a luxury - and the tools for it should reflect that.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<app-ad-slot />
|
||||||
|
|
||||||
|
<!-- Privacy as a right -->
|
||||||
|
<div class="section-fade mb-16">
|
||||||
|
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">Privacy Is a Right, Not a Feature</h2>
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
Most communication platforms collect everything: who you talk to, when, for how long, what you share. They build profiles of your social
|
||||||
|
graph, your habits, your interests. Even services that claim to care about privacy still store metadata on their servers.
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
Toju is architecturally different. Your data goes directly from your device to your friend's device. We don't have your messages. We don't
|
||||||
|
have your files. We don't have your call history. Not because we promised not to look - but because the data never touches our
|
||||||
|
infrastructure. We built the technology so that
|
||||||
|
<strong class="text-foreground">privacy isn't something we offer; it's something we literally cannot violate.</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Better world -->
|
||||||
|
<div class="section-fade mb-16">
|
||||||
|
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">Built from the Heart</h2>
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
Toju wasn't born in a boardroom with revenue projections. It was born from frustration - frustration with being the product, with watching
|
||||||
|
friends get locked out of features they used to have for free, with the growing feeling that the tools we depend on daily don't actually
|
||||||
|
serve our interests.
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
We build Toju because we genuinely want to make the world a little better. The internet was supposed to connect people freely, and somewhere
|
||||||
|
along the way, that mission got hijacked by business models that exploit the very connections they facilitate.
|
||||||
|
<strong class="text-foreground">Toju is our small act of reclaiming that original promise.</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Open source -->
|
||||||
|
<div class="section-fade mb-16">
|
||||||
|
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6">Transparent by Default</h2>
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
Every line of Toju's code is publicly available. You can read it, audit it, contribute to it, or fork it and build your own version. This
|
||||||
|
isn't a marketing decision - it's an accountability decision. When you can see exactly how the software works, you never have to take our
|
||||||
|
word for anything.
|
||||||
|
</p>
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
Open source also means Toju belongs to its community, not to a company. Even if we stopped development tomorrow, the project lives on. Your
|
||||||
|
communication infrastructure shouldn't depend on a single organization's survival.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Commitment -->
|
||||||
|
<div class="section-fade rounded-2xl border border-purple-500/20 bg-gradient-to-br from-purple-950/20 to-violet-950/20 p-8 md:p-10">
|
||||||
|
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-6 !mt-0">Our Promise</h2>
|
||||||
|
<ul class="space-y-4 text-muted-foreground !list-none !pl-0">
|
||||||
|
<li class="flex items-start gap-3">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-purple-400 flex-shrink-0 mt-0.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>We will <strong class="text-foreground">never</strong> lock features behind a paywall.</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-3">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-purple-400 flex-shrink-0 mt-0.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>We will <strong class="text-foreground">never</strong> sell, monetize, or harvest your data.</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-3">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-purple-400 flex-shrink-0 mt-0.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>We will <strong class="text-foreground">always</strong> keep the source code open and auditable.</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start gap-3">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-purple-400 flex-shrink-0 mt-0.5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>We will <strong class="text-foreground">always</strong> put users before profit.</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="text-muted-foreground mt-6 text-sm">- The Myxelium team</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Support CTA -->
|
||||||
|
<section class="container mx-auto px-6">
|
||||||
|
<div class="section-fade max-w-2xl mx-auto text-center">
|
||||||
|
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-4">Help us keep going</h2>
|
||||||
|
<p class="text-muted-foreground mb-8 leading-relaxed">
|
||||||
|
If Toju's mission resonates with you, consider supporting the project. Every contribution helps us keep the lights on and development moving
|
||||||
|
forward - without ever compromising our values.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
|
<a
|
||||||
|
href="https://buymeacoffee.com/myxelium"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-gradient-to-r from-yellow-500 to-amber-500 text-black font-semibold hover:from-yellow-400 hover:to-amber-400 transition-all shadow-lg shadow-yellow-500/25"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/images/buymeacoffee.png"
|
||||||
|
alt=""
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
class="w-5 h-5 object-contain"
|
||||||
|
/>
|
||||||
|
Buy us a coffee
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
routerLink="/downloads"
|
||||||
|
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium hover:border-purple-500/30 transition-all"
|
||||||
|
>
|
||||||
|
Download Toju
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
45
website/src/app/pages/philosophy/philosophy.component.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
AfterViewInit,
|
||||||
|
OnDestroy,
|
||||||
|
inject,
|
||||||
|
PLATFORM_ID
|
||||||
|
} from '@angular/core';
|
||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { SeoService } from '../../services/seo.service';
|
||||||
|
import { ScrollAnimationService } from '../../services/scroll-animation.service';
|
||||||
|
import { AdSlotComponent } from '../../components/ad-slot/ad-slot.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-philosophy',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink, AdSlotComponent],
|
||||||
|
templateUrl: './philosophy.component.html'
|
||||||
|
})
|
||||||
|
export class PhilosophyComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
private readonly seoService = inject(SeoService);
|
||||||
|
private readonly scrollAnimation = inject(ScrollAnimationService);
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.seoService.update({
|
||||||
|
title: 'Our Philosophy - Why We Build Toju',
|
||||||
|
description:
|
||||||
|
'Toju exists because privacy is a right, not a premium feature. No paywalls, no data harvesting, no predatory '
|
||||||
|
+ 'pricing. Learn why we build free, open-source communication tools.',
|
||||||
|
url: 'https://toju.app/philosophy'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
setTimeout(() => this.scrollAnimation.init(), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.scrollAnimation.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
261
website/src/app/pages/what-is-toju/what-is-toju.component.html
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
<div class="min-h-screen pt-32 pb-20">
|
||||||
|
<!-- Hero -->
|
||||||
|
<section class="container mx-auto px-6 mb-24">
|
||||||
|
<div class="max-w-3xl mx-auto text-center">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-sm font-medium mb-6"
|
||||||
|
>
|
||||||
|
The Big Picture
|
||||||
|
</div>
|
||||||
|
<h1 class="text-4xl md:text-6xl font-extrabold text-foreground mb-6">What is <span class="gradient-text">Toju</span>?</h1>
|
||||||
|
<p class="text-lg text-muted-foreground leading-relaxed">
|
||||||
|
Toju is a communication app that lets you voice chat, share your screen, send files, and message your friends - all without your data passing
|
||||||
|
through someone else's servers. Think of it as your own private phone line that nobody can tap into.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-ad-slot />
|
||||||
|
|
||||||
|
<!-- How it works -->
|
||||||
|
<section class="container mx-auto px-6 mb-24">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold text-foreground mb-12 text-center section-fade">
|
||||||
|
How does it <span class="gradient-text">work</span>?
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="grid gap-8">
|
||||||
|
<!-- Step 1 -->
|
||||||
|
<div class="section-fade relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 md:p-10">
|
||||||
|
<div class="flex items-start gap-6">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 w-12 h-12 rounded-full bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400 font-bold text-lg"
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold text-foreground mb-3">You connect directly to your friends</h3>
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
When you start a call or send a file on Toju, your data travels directly from your device to your friend's device. There's no company
|
||||||
|
server in the middle storing your conversations, listening to your calls, or scanning your files. This is called
|
||||||
|
<strong class="text-foreground">peer-to-peer</strong> - it's like having a direct road between your houses instead of going through a
|
||||||
|
toll booth.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2 -->
|
||||||
|
<div class="section-fade relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 md:p-10">
|
||||||
|
<div class="flex items-start gap-6">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 w-12 h-12 rounded-full bg-violet-500/10 border border-violet-500/20 flex items-center justify-center text-violet-400 font-bold text-lg"
|
||||||
|
>
|
||||||
|
2
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold text-foreground mb-3">A tiny helper gets you connected</h3>
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
The only thing a server does is help your device find your friend's device - like a mutual friend introducing you at a party. Once
|
||||||
|
you're connected, the server steps out of the picture entirely. It never sees what you say, share, or send. This helper is called a
|
||||||
|
<strong class="text-foreground">signal server</strong>, and you can even run your own if you'd like.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3 -->
|
||||||
|
<div class="section-fade relative rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8 md:p-10">
|
||||||
|
<div class="flex items-start gap-6">
|
||||||
|
<div
|
||||||
|
class="flex-shrink-0 w-12 h-12 rounded-full bg-pink-500/10 border border-pink-500/20 flex items-center justify-center text-pink-400 font-bold text-lg"
|
||||||
|
>
|
||||||
|
3
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold text-foreground mb-3">No limits because there are no middlemen</h3>
|
||||||
|
<p class="text-muted-foreground leading-relaxed">
|
||||||
|
Since your data doesn't pass through our servers, we don't need to pay for massive infrastructure. That's why Toju can offer
|
||||||
|
<strong class="text-foreground">unlimited screen sharing, file transfers of any size, and high-quality voice</strong> - all completely
|
||||||
|
free. There's no business reason to limit what you can do, and we never will.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-ad-slot />
|
||||||
|
|
||||||
|
<!-- Why designed this way -->
|
||||||
|
<section class="container mx-auto px-6 mb-24">
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold text-foreground mb-12 text-center section-fade">
|
||||||
|
Why is it <span class="gradient-text">designed</span> this way?
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 gap-8">
|
||||||
|
<div class="section-fade rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-emerald-500/10 flex items-center justify-center mb-4">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-emerald-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-foreground mb-3">Privacy by Architecture</h3>
|
||||||
|
<p class="text-muted-foreground leading-relaxed text-sm">
|
||||||
|
We didn't just add privacy as a feature - we built the entire app around it. When there's no central server handling your data, there's
|
||||||
|
nothing to hack, subpoena, or sell. Your privacy isn't protected by a promise; it's protected by how the technology works.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-fade rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center mb-4">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-blue-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-foreground mb-3">Performance Without Compromise</h3>
|
||||||
|
<p class="text-muted-foreground leading-relaxed text-sm">
|
||||||
|
Direct connections mean lower latency. Your voice reaches your friend faster. Your screen share is smoother. Your file arrives in the time
|
||||||
|
it actually takes to transfer - not in the time it takes to upload, store, then download.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-fade rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-amber-500/10 flex items-center justify-center mb-4">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-amber-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-foreground mb-3">Sustainable & Free</h3>
|
||||||
|
<p class="text-muted-foreground leading-relaxed text-sm">
|
||||||
|
Running a traditional chat service with millions of users costs enormous amounts of money for servers. That cost gets passed to you. With
|
||||||
|
peer-to-peer, the only infrastructure we run is a tiny coordination server - costing almost nothing. That's how we keep it free
|
||||||
|
permanently.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-fade rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-8">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-purple-500/10 flex items-center justify-center mb-4">
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-purple-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-foreground mb-3">Independence & Freedom</h3>
|
||||||
|
<p class="text-muted-foreground leading-relaxed text-sm">
|
||||||
|
You're not locked into our ecosystem. The code is open source. You can run your own server. If we ever disappeared tomorrow, you could
|
||||||
|
still use Toju. Your communication tools should belong to you, not a corporation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FAQ-style section -->
|
||||||
|
<section class="container mx-auto px-6 mb-24">
|
||||||
|
<div class="max-w-3xl mx-auto section-fade">
|
||||||
|
<h2 class="text-3xl md:text-4xl font-bold text-foreground mb-12 text-center">Common <span class="gradient-text">Questions</span></h2>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-6 md:p-8">
|
||||||
|
<h3 class="text-lg font-semibold text-foreground mb-2">Is Toju really free? What's the catch?</h3>
|
||||||
|
<p class="text-muted-foreground leading-relaxed text-sm">
|
||||||
|
Yes, it's really free. There is no catch. Because Toju uses peer-to-peer connections, we don't need expensive server infrastructure. Our
|
||||||
|
costs are minimal, and we fund development through community support and donations. Every feature is available to everyone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-6 md:p-8">
|
||||||
|
<h3 class="text-lg font-semibold text-foreground mb-2">Do I need technical knowledge to use Toju?</h3>
|
||||||
|
<p class="text-muted-foreground leading-relaxed text-sm">
|
||||||
|
Not at all. Toju works like any other chat app - download it, create an account, and start talking. All the peer-to-peer magic happens
|
||||||
|
behind the scenes. You just enjoy the benefits of better privacy, performance, and no limits.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-6 md:p-8">
|
||||||
|
<h3 class="text-lg font-semibold text-foreground mb-2">What does "self-host the signal server" mean?</h3>
|
||||||
|
<p class="text-muted-foreground leading-relaxed text-sm">
|
||||||
|
The signal server is a tiny program that helps users find each other online. We run one by default, but if you prefer complete control,
|
||||||
|
you can run your own copy on your own hardware. It's like having your own private phone directory - only people you invite can use it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-border/30 bg-card/30 backdrop-blur-sm p-6 md:p-8">
|
||||||
|
<h3 class="text-lg font-semibold text-foreground mb-2">Is my data safe?</h3>
|
||||||
|
<p class="text-muted-foreground leading-relaxed text-sm">
|
||||||
|
Your conversations, files, and calls go directly between you and the person you're talking to. They never pass through or get stored on
|
||||||
|
our servers. Even if someone broke into our server, there would be nothing to find - because we never had your data in the first place.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<section class="container mx-auto px-6">
|
||||||
|
<div
|
||||||
|
class="section-fade max-w-2xl mx-auto text-center rounded-2xl border border-purple-500/20 bg-gradient-to-br from-purple-950/20 to-violet-950/20 p-12"
|
||||||
|
>
|
||||||
|
<h2 class="text-2xl md:text-3xl font-bold text-foreground mb-4">Ready to try it?</h2>
|
||||||
|
<p class="text-muted-foreground mb-8">Available on Windows, Linux, and in your browser. Always free.</p>
|
||||||
|
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
|
<a
|
||||||
|
routerLink="/downloads"
|
||||||
|
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-gradient-to-r from-purple-600 to-violet-600 text-white font-semibold hover:from-purple-500 hover:to-violet-500 transition-all shadow-lg shadow-purple-500/25"
|
||||||
|
>
|
||||||
|
Download Toju
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://web.toju.app/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl border border-border/50 bg-card/50 text-foreground font-medium hover:border-purple-500/30 transition-all"
|
||||||
|
>
|
||||||
|
Open in Browser
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
45
website/src/app/pages/what-is-toju/what-is-toju.component.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
AfterViewInit,
|
||||||
|
OnDestroy,
|
||||||
|
inject,
|
||||||
|
PLATFORM_ID
|
||||||
|
} from '@angular/core';
|
||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { SeoService } from '../../services/seo.service';
|
||||||
|
import { ScrollAnimationService } from '../../services/scroll-animation.service';
|
||||||
|
import { AdSlotComponent } from '../../components/ad-slot/ad-slot.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-what-is-toju',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterLink, AdSlotComponent],
|
||||||
|
templateUrl: './what-is-toju.component.html'
|
||||||
|
})
|
||||||
|
export class WhatIsTojuComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
private readonly seoService = inject(SeoService);
|
||||||
|
private readonly scrollAnimation = inject(ScrollAnimationService);
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.seoService.update({
|
||||||
|
title: 'What is Toju? - How It Works',
|
||||||
|
description:
|
||||||
|
'Learn how Toju\'s peer-to-peer technology delivers free, private, unlimited voice calls, screen sharing, and '
|
||||||
|
+ 'file transfers without centralized servers.',
|
||||||
|
url: 'https://toju.app/what-is-toju'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
if (isPlatformBrowser(this.platformId)) {
|
||||||
|
setTimeout(() => this.scrollAnimation.init(), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.scrollAnimation.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
14
website/src/app/services/ad.service.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class AdService {
|
||||||
|
readonly adsEnabled = signal(false);
|
||||||
|
|
||||||
|
enableAds(): void {
|
||||||
|
this.adsEnabled.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
disableAds(): void {
|
||||||
|
this.adsEnabled.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
260
website/src/app/services/release.service.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
PLATFORM_ID,
|
||||||
|
inject
|
||||||
|
} from '@angular/core';
|
||||||
|
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
|
||||||
|
|
||||||
|
export interface ReleaseAsset {
|
||||||
|
name: string;
|
||||||
|
browser_download_url: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Release {
|
||||||
|
tag_name: string;
|
||||||
|
name: string;
|
||||||
|
published_at: string;
|
||||||
|
body: string;
|
||||||
|
assets: ReleaseAsset[];
|
||||||
|
html_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetectedOS {
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
filePattern: RegExp;
|
||||||
|
ymlFile: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WINDOWS_SUFFIXES = ['.exe', '.msi'];
|
||||||
|
const WINDOWS_HINTS = [
|
||||||
|
'setup',
|
||||||
|
'win',
|
||||||
|
'windows'
|
||||||
|
];
|
||||||
|
const MAC_SUFFIXES = ['.dmg', '.pkg'];
|
||||||
|
const MAC_HINTS = [
|
||||||
|
'mac',
|
||||||
|
'macos',
|
||||||
|
'osx',
|
||||||
|
'darwin'
|
||||||
|
];
|
||||||
|
const LINUX_SUFFIXES = [
|
||||||
|
'.appimage',
|
||||||
|
'.deb',
|
||||||
|
'.rpm'
|
||||||
|
];
|
||||||
|
const LINUX_HINTS = ['linux'];
|
||||||
|
const ARCHIVE_SUFFIXES = [
|
||||||
|
'.zip',
|
||||||
|
'.tar',
|
||||||
|
'.tar.gz',
|
||||||
|
'.tgz',
|
||||||
|
'.tar.xz',
|
||||||
|
'.7z',
|
||||||
|
'.rar'
|
||||||
|
];
|
||||||
|
const DIRECT_RELEASES_API_URL = 'https://git.azaaxin.com/api/v1/repos/myxelium/Toju/releases';
|
||||||
|
const PROXY_RELEASES_API_URL = '/api/releases';
|
||||||
|
|
||||||
|
function matchesAssetPattern(name: string, suffixes: string[], hints: string[] = []): boolean {
|
||||||
|
if (suffixes.some((suffix) => name.endsWith(suffix))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = name.split(/[^a-z0-9]+/).filter(Boolean);
|
||||||
|
|
||||||
|
return hints.some((hint) => tokens.includes(hint));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ReleaseService {
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
|
||||||
|
private cachedReleases: Release[] | null = null;
|
||||||
|
private fetchPromise: Promise<Release[]> | null = null;
|
||||||
|
|
||||||
|
detectOS(): DetectedOS {
|
||||||
|
if (!isPlatformBrowser(this.platformId)) {
|
||||||
|
return {
|
||||||
|
name: 'Linux',
|
||||||
|
icon: null,
|
||||||
|
filePattern: /\.AppImage$/i,
|
||||||
|
ymlFile: 'latest-linux.yml'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const userAgent = navigator.userAgent.toLowerCase();
|
||||||
|
|
||||||
|
if (userAgent.includes('win')) {
|
||||||
|
return {
|
||||||
|
name: 'Windows',
|
||||||
|
icon: null,
|
||||||
|
filePattern: /\.exe$/i,
|
||||||
|
ymlFile: 'latest.yml'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userAgent.includes('mac')) {
|
||||||
|
return {
|
||||||
|
name: 'macOS',
|
||||||
|
icon: null,
|
||||||
|
filePattern: /\.dmg$/i,
|
||||||
|
ymlFile: 'latest-mac.yml'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUbuntuDebian = userAgent.includes('ubuntu') || userAgent.includes('debian');
|
||||||
|
|
||||||
|
if (isUbuntuDebian) {
|
||||||
|
return {
|
||||||
|
name: 'Linux (deb)',
|
||||||
|
icon: null,
|
||||||
|
filePattern: /\.deb$/i,
|
||||||
|
ymlFile: 'latest-linux.yml'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'Linux',
|
||||||
|
icon: null,
|
||||||
|
filePattern: /\.AppImage$/i,
|
||||||
|
ymlFile: 'latest-linux.yml'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchReleases(): Promise<Release[]> {
|
||||||
|
if (this.cachedReleases) {
|
||||||
|
return Promise.resolve(this.cachedReleases);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlatformServer(this.platformId)) {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.fetchPromise) {
|
||||||
|
return this.fetchPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fetchPromise = this.fetchReleasesInternal();
|
||||||
|
|
||||||
|
return this.fetchPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLatestRelease(): Promise<Release | null> {
|
||||||
|
const releases = await this.fetchReleases();
|
||||||
|
|
||||||
|
return releases.length > 0 ? releases[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDownloadUrl(os: DetectedOS): Promise<string | null> {
|
||||||
|
const release = await this.getLatestRelease();
|
||||||
|
|
||||||
|
if (!release) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingAsset = release.assets.find(
|
||||||
|
(releaseAsset) => os.filePattern.test(releaseAsset.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
return matchingAsset?.browser_download_url ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAssetOS(name: string): string {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
|
||||||
|
if (matchesAssetPattern(lower, WINDOWS_SUFFIXES, WINDOWS_HINTS)) {
|
||||||
|
return 'Windows';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesAssetPattern(lower, MAC_SUFFIXES, MAC_HINTS)) {
|
||||||
|
return 'macOS';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesAssetPattern(lower, LINUX_SUFFIXES, LINUX_HINTS)) {
|
||||||
|
return 'Linux';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesAssetPattern(lower, ARCHIVE_SUFFIXES)) {
|
||||||
|
return 'Archive';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower.endsWith('.wasm')) {
|
||||||
|
return 'Web';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Other';
|
||||||
|
}
|
||||||
|
|
||||||
|
formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) {
|
||||||
|
return '0 B';
|
||||||
|
}
|
||||||
|
|
||||||
|
const kilobyte = 1024;
|
||||||
|
const units = [
|
||||||
|
'B',
|
||||||
|
'KB',
|
||||||
|
'MB',
|
||||||
|
'GB'
|
||||||
|
];
|
||||||
|
const unitIndex = Math.floor(Math.log(bytes) / Math.log(kilobyte));
|
||||||
|
|
||||||
|
return `${parseFloat((bytes / Math.pow(kilobyte, unitIndex)).toFixed(1))} ${units[unitIndex]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchReleasesInternal(): Promise<Release[]> {
|
||||||
|
try {
|
||||||
|
const data = await this.fetchReleasesFromAvailableEndpoints();
|
||||||
|
|
||||||
|
this.cachedReleases = Array.isArray(data) ? data : [data];
|
||||||
|
|
||||||
|
return this.cachedReleases;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch releases:', error);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
this.fetchPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchReleasesFromAvailableEndpoints(): Promise<Release[] | Release> {
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
for (const endpoint of this.getReleaseEndpoints()) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
headers: { Accept: 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError instanceof Error
|
||||||
|
? lastError
|
||||||
|
: new Error('Failed to fetch releases from all configured endpoints.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private getReleaseEndpoints(): string[] {
|
||||||
|
if (!isPlatformBrowser(this.platformId)) {
|
||||||
|
return [PROXY_RELEASES_API_URL, DIRECT_RELEASES_API_URL];
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = window.location.hostname;
|
||||||
|
const isLocalHost = hostname === 'localhost' || hostname === '127.0.0.1';
|
||||||
|
|
||||||
|
return isLocalHost
|
||||||
|
? [PROXY_RELEASES_API_URL, DIRECT_RELEASES_API_URL]
|
||||||
|
: [DIRECT_RELEASES_API_URL, PROXY_RELEASES_API_URL];
|
||||||
|
}
|
||||||
|
}
|
||||||
41
website/src/app/services/scroll-animation.service.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
inject,
|
||||||
|
PLATFORM_ID
|
||||||
|
} from '@angular/core';
|
||||||
|
import { isPlatformBrowser } from '@angular/common';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ScrollAnimationService {
|
||||||
|
private readonly platformId = inject(PLATFORM_ID);
|
||||||
|
private observer: IntersectionObserver | null = null;
|
||||||
|
|
||||||
|
init(): void {
|
||||||
|
if (!isPlatformBrowser(this.platformId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
entry.target.classList.add('visible');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.1, rootMargin: '0px 0px -50px 0px' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Observe all elements with section-fade class
|
||||||
|
document.querySelectorAll('.section-fade').forEach((el) => {
|
||||||
|
this.observer?.observe(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
observe(element: HTMLElement): void {
|
||||||
|
this.observer?.observe(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
this.observer?.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
37
website/src/app/services/seo.service.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { Injectable, inject } from '@angular/core';
|
||||||
|
import { Meta, Title } from '@angular/platform-browser';
|
||||||
|
|
||||||
|
interface SeoData {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
url?: string;
|
||||||
|
image?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SeoService {
|
||||||
|
private readonly meta = inject(Meta);
|
||||||
|
private readonly title = inject(Title);
|
||||||
|
|
||||||
|
update(data: SeoData): void {
|
||||||
|
const fullTitle = `${data.title} - Toju`;
|
||||||
|
|
||||||
|
this.title.setTitle(fullTitle);
|
||||||
|
|
||||||
|
this.meta.updateTag({ name: 'description', content: data.description });
|
||||||
|
this.meta.updateTag({ property: 'og:title', content: fullTitle });
|
||||||
|
this.meta.updateTag({ property: 'og:description', content: data.description });
|
||||||
|
this.meta.updateTag({ name: 'twitter:title', content: fullTitle });
|
||||||
|
this.meta.updateTag({ name: 'twitter:description', content: data.description });
|
||||||
|
|
||||||
|
if (data.url) {
|
||||||
|
this.meta.updateTag({ property: 'og:url', content: data.url });
|
||||||
|
this.meta.updateTag({ rel: 'canonical', href: data.url });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.image) {
|
||||||
|
this.meta.updateTag({ property: 'og:image', content: data.image });
|
||||||
|
this.meta.updateTag({ name: 'twitter:image', content: data.image });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
website/src/images/buymeacoffee.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
website/src/images/gitea.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
website/src/images/github.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
website/src/images/linux/64x64.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
website/src/images/macos/64x64.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
website/src/images/misc/file.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
website/src/images/misc/zip.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
website/src/images/screenshots/filedownload.png
Normal file
|
After Width: | Height: | Size: 356 KiB |
BIN
website/src/images/screenshots/gif.png
Normal file
|
After Width: | Height: | Size: 505 KiB |
BIN
website/src/images/screenshots/music.png
Normal file
|
After Width: | Height: | Size: 366 KiB |
BIN
website/src/images/screenshots/screenshare_gaming.png
Normal file
|
After Width: | Height: | Size: 816 KiB |
BIN
website/src/images/screenshots/screenshot_main.png
Normal file
|
After Width: | Height: | Size: 281 KiB |
BIN
website/src/images/screenshots/serverViewScreen.png
Normal file
|
After Width: | Height: | Size: 367 KiB |
BIN
website/src/images/screenshots/videos.png
Normal file
|
After Width: | Height: | Size: 549 KiB |
BIN
website/src/images/toju-logo-transparent.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
website/src/images/windows/64x64.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
108
website/src/index.html
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html
|
||||||
|
lang="en"
|
||||||
|
class="dark"
|
||||||
|
>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Toju — Free Peer-to-Peer Voice, Video & Chat</title>
|
||||||
|
<base href="/" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Toju is a free, open-source, peer-to-peer communication app. Crystal-clear voice calls, unlimited screen sharing, no file size limits, and complete privacy. No data harvesting. No paywalls. Ever."
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="keywords"
|
||||||
|
content="peer to peer chat, p2p voice calls, free screen sharing, open source chat app, private messaging, file sharing no limit, gaming voice chat, free voice chat, encrypted communication"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="author"
|
||||||
|
content="Myxelium"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="robots"
|
||||||
|
content="index, follow"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="theme-color"
|
||||||
|
content="#8b5cf6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta
|
||||||
|
property="og:type"
|
||||||
|
content="website"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
property="og:url"
|
||||||
|
content="https://toju.app/"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
property="og:title"
|
||||||
|
content="Toju — Free Peer-to-Peer Voice, Video & Chat"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="Crystal-clear voice calls, unlimited screen sharing, and private messaging. 100% free, open source, peer-to-peer."
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
property="og:image"
|
||||||
|
content="https://toju.app/og-image.png"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
property="og:site_name"
|
||||||
|
content="Toju"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta
|
||||||
|
name="twitter:card"
|
||||||
|
content="summary_large_image"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="twitter:title"
|
||||||
|
content="Toju — Free Peer-to-Peer Voice, Video & Chat"
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="twitter:description"
|
||||||
|
content="Crystal-clear voice calls, unlimited screen sharing, and private messaging. 100% free, open source, peer-to-peer."
|
||||||
|
/>
|
||||||
|
<meta
|
||||||
|
name="twitter:image"
|
||||||
|
content="https://toju.app/og-image.png"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<link
|
||||||
|
rel="canonical"
|
||||||
|
href="https://toju.app/"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/x-icon"
|
||||||
|
href="favicon.ico"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Structured Data -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "SoftwareApplication",
|
||||||
|
"name": "Toju",
|
||||||
|
"operatingSystem": "Windows, Linux",
|
||||||
|
"applicationCategory": "CommunicationApplication",
|
||||||
|
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" },
|
||||||
|
"author": { "@type": "Organization", "name": "Myxelium", "url": "https://github.com/Myxelium" },
|
||||||
|
"description": "Free, open-source, peer-to-peer communication app with voice calls, screen sharing, and file sharing.",
|
||||||
|
"url": "https://toju.app/",
|
||||||
|
"downloadUrl": "https://toju.app/downloads"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<app-root></app-root>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7
website/src/main.server.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { BootstrapContext, bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { AppComponent } from './app/app.component';
|
||||||
|
import { config } from './app/app.config.server';
|
||||||
|
|
||||||
|
const bootstrap = (context: BootstrapContext) => bootstrapApplication(AppComponent, config, context);
|
||||||
|
|
||||||
|
export default bootstrap;
|
||||||
6
website/src/main.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
|
import { appConfig } from './app/app.config';
|
||||||
|
import { AppComponent } from './app/app.component';
|
||||||
|
|
||||||
|
bootstrapApplication(AppComponent, appConfig)
|
||||||
|
.catch((err) => console.error(err));
|
||||||
92
website/src/server.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { APP_BASE_HREF } from '@angular/common';
|
||||||
|
import { CommonEngine, isMainModule } from '@angular/ssr/node';
|
||||||
|
import express from 'express';
|
||||||
|
import {
|
||||||
|
dirname,
|
||||||
|
join,
|
||||||
|
resolve
|
||||||
|
} from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import bootstrap from './main.server';
|
||||||
|
|
||||||
|
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const browserDistFolder = resolve(serverDistFolder, '../browser');
|
||||||
|
const indexHtml = join(serverDistFolder, 'index.server.html');
|
||||||
|
const app = express();
|
||||||
|
const commonEngine = new CommonEngine();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy endpoint for Gitea releases API to avoid CORS issues.
|
||||||
|
*/
|
||||||
|
app.get('/api/releases', async (_request, response) => {
|
||||||
|
try {
|
||||||
|
const upstreamResponse = await fetch('https://git.azaaxin.com/api/v1/repos/myxelium/Toju/releases', {
|
||||||
|
headers: { Accept: 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!upstreamResponse.ok) {
|
||||||
|
response.status(upstreamResponse.status).json({
|
||||||
|
error: `Upstream returned ${upstreamResponse.status}`
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await upstreamResponse.json();
|
||||||
|
|
||||||
|
response.setHeader('Cache-Control', 'public, max-age=300');
|
||||||
|
response.json(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Proxy fetch error:', error);
|
||||||
|
response.status(502).json({
|
||||||
|
error: 'Failed to fetch releases from upstream'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serve static files from /browser.
|
||||||
|
*/
|
||||||
|
app.get(
|
||||||
|
'**',
|
||||||
|
express.static(browserDistFolder, {
|
||||||
|
maxAge: '1y',
|
||||||
|
index: 'index.html'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle all other requests by rendering the Angular application.
|
||||||
|
*/
|
||||||
|
app.get('**', (request, response, next) => {
|
||||||
|
const {
|
||||||
|
protocol,
|
||||||
|
originalUrl,
|
||||||
|
baseUrl,
|
||||||
|
headers
|
||||||
|
} = request;
|
||||||
|
|
||||||
|
commonEngine
|
||||||
|
.render({
|
||||||
|
bootstrap,
|
||||||
|
documentFilePath: indexHtml,
|
||||||
|
url: `${protocol}://${headers.host}${originalUrl}`,
|
||||||
|
publicPath: browserDistFolder,
|
||||||
|
providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }]
|
||||||
|
})
|
||||||
|
.then((html) => response.send(html))
|
||||||
|
.catch((error) => next(error));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the server if this module is the main entry point.
|
||||||
|
*/
|
||||||
|
if (isMainModule(import.meta.url)) {
|
||||||
|
const port = process.env['PORT'] || 4000;
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Node Express server listening on http://localhost:${port}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default app;
|
||||||
117
website/src/styles.scss
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;700&display=swap');
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 240 10% 3.9%;
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
--card: 240 10% 6%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
--primary: 262.1 83.3% 57.8%;
|
||||||
|
--primary-foreground: 210 20% 98%;
|
||||||
|
--secondary: 240 3.7% 15.9%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 240 3.7% 15.9%;
|
||||||
|
--muted-foreground: 240 5% 64.9%;
|
||||||
|
--accent: 262.1 83.3% 57.8%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--border: 240 3.7% 15.9%;
|
||||||
|
--input: 240 3.7% 15.9%;
|
||||||
|
--ring: 262.1 83.3% 57.8%;
|
||||||
|
--radius: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground font-sans antialiased;
|
||||||
|
font-feature-settings: 'rlig' 1, 'calt' 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: hsl(var(--background));
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--muted));
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--muted-foreground) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility classes */
|
||||||
|
.glass {
|
||||||
|
backdrop-filter: blur(16px) saturate(180%);
|
||||||
|
background: hsl(var(--background) / 0.7);
|
||||||
|
border: 1px solid hsl(var(--border) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(135deg, hsl(var(--primary)), hsl(280 90% 70%), hsl(320 80% 65%));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-border {
|
||||||
|
position: relative;
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
padding: 1px;
|
||||||
|
background: linear-gradient(135deg, hsl(var(--primary)), hsl(280 90% 70%), transparent);
|
||||||
|
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask-composite: exclude;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-fade {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
transition: opacity 0.8s ease-out, transform 0.8s ease-out;
|
||||||
|
&.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Parallax container */
|
||||||
|
.parallax-section {
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ad placeholder */
|
||||||
|
.ad-slot {
|
||||||
|
display: none;
|
||||||
|
&.ad-enabled {
|
||||||
|
display: block;
|
||||||
|
min-height: 90px;
|
||||||
|
background: hsl(var(--card));
|
||||||
|
border: 1px dashed hsl(var(--border));
|
||||||
|
border-radius: var(--radius);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
88
website/tailwind.config.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: 'class',
|
||||||
|
content: ['./src/**/*.{html,ts}'],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: '2rem',
|
||||||
|
screens: {
|
||||||
|
'2xl': '1400px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||||
|
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.6s ease-out forwards',
|
||||||
|
'fade-in-up': 'fadeInUp 0.8s ease-out forwards',
|
||||||
|
'slide-in-left': 'slideInLeft 0.8s ease-out forwards',
|
||||||
|
'slide-in-right': 'slideInRight 0.8s ease-out forwards',
|
||||||
|
'float': 'float 6s ease-in-out infinite',
|
||||||
|
'glow-pulse': 'glowPulse 3s ease-in-out infinite',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
|
fadeInUp: {
|
||||||
|
'0%': { opacity: '0', transform: 'translateY(30px)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
slideInLeft: {
|
||||||
|
'0%': { opacity: '0', transform: 'translateX(-30px)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translateX(0)' },
|
||||||
|
},
|
||||||
|
slideInRight: {
|
||||||
|
'0%': { opacity: '0', transform: 'translateX(30px)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translateX(0)' },
|
||||||
|
},
|
||||||
|
float: {
|
||||||
|
'0%, 100%': { transform: 'translateY(0)' },
|
||||||
|
'50%': { transform: 'translateY(-20px)' },
|
||||||
|
},
|
||||||
|
glowPulse: {
|
||||||
|
'0%, 100%': { boxShadow: '0 0 20px rgba(139, 92, 246, 0.3)' },
|
||||||
|
'50%': { boxShadow: '0 0 40px rgba(139, 92, 246, 0.6)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require('@tailwindcss/typography')],
|
||||||
|
};
|
||||||
|
|
||||||
19
website/tsconfig.app.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/app",
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"src/main.ts",
|
||||||
|
"src/main.server.ts",
|
||||||
|
"src/server.ts"
|
||||||
|
],
|
||||||
|
"include": [
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
27
website/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"compileOnSave": false,
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist/out-tsc",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noPropertyAccessFromIndexSignature": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"importHelpers": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ES2022"
|
||||||
|
},
|
||||||
|
"angularCompilerOptions": {
|
||||||
|
"enableI18nLegacyMessageIdFormat": false,
|
||||||
|
"strictInjectionParameters": true,
|
||||||
|
"strictInputAccessModifiers": true,
|
||||||
|
"strictTemplates": true
|
||||||
|
}
|
||||||
|
}
|
||||||
15
website/tsconfig.spec.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||||
|
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/spec",
|
||||||
|
"types": [
|
||||||
|
"jasmine"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.spec.ts",
|
||||||
|
"src/**/*.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||