Compare commits
4 Commits
v1.0.32
...
c8814c3e2c
| Author | SHA1 | Date | |
|---|---|---|---|
| c8814c3e2c | |||
| 45e0b09af8 | |||
| 106212ef3d | |||
| be465fd297 |
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
|
||||
@@ -3,7 +3,6 @@ import { readDesktopSettings } from '../desktop-settings';
|
||||
|
||||
export function configureAppFlags(): void {
|
||||
linuxSpecificFlags();
|
||||
audioFlags();
|
||||
networkFlags();
|
||||
setupGpuEncodingFlags();
|
||||
chromiumFlags();
|
||||
@@ -15,21 +14,40 @@ function chromiumFlags(): void {
|
||||
|
||||
// Suppress Autofill devtools errors
|
||||
app.commandLine.appendSwitch('disable-features', 'Autofill,AutofillAssistant,AutofillServerCommunication');
|
||||
}
|
||||
|
||||
function audioFlags(): void {
|
||||
// Collect all enabled features into a single switch to avoid later calls overwriting earlier ones
|
||||
const enabledFeatures: string[] = [];
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
// Use the new PipeWire-based audio pipeline on Linux for better screen share audio capture support
|
||||
app.commandLine.appendSwitch('enable-features', 'AudioServiceOutOfProcess');
|
||||
// PipeWire-based audio pipeline for screen share audio capture
|
||||
enabledFeatures.push('AudioServiceOutOfProcess');
|
||||
// PipeWire-based screen capture so the xdg-desktop-portal system picker works
|
||||
enabledFeatures.push('WebRTCPipeWireCapturer');
|
||||
}
|
||||
|
||||
const desktopSettings = readDesktopSettings();
|
||||
|
||||
if (process.platform === 'linux' && desktopSettings.vaapiVideoEncode) {
|
||||
enabledFeatures.push('VaapiVideoEncode');
|
||||
}
|
||||
|
||||
if (enabledFeatures.length > 0) {
|
||||
app.commandLine.appendSwitch('enable-features', enabledFeatures.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
function linuxSpecificFlags(): void {
|
||||
// Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues
|
||||
if (process.platform === 'linux') {
|
||||
app.commandLine.appendSwitch('no-sandbox');
|
||||
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
||||
if (process.platform !== 'linux') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable sandbox on Linux to avoid SUID / /tmp shared-memory issues
|
||||
app.commandLine.appendSwitch('no-sandbox');
|
||||
app.commandLine.appendSwitch('disable-dev-shm-usage');
|
||||
|
||||
// Auto-detect Wayland vs X11 so the xdg-desktop-portal system picker
|
||||
// works for screen capture on Wayland compositors
|
||||
app.commandLine.appendSwitch('ozone-platform-hint', 'auto');
|
||||
}
|
||||
|
||||
function networkFlags(): void {
|
||||
@@ -46,11 +64,6 @@ function setupGpuEncodingFlags(): void {
|
||||
app.disableHardwareAcceleration();
|
||||
}
|
||||
|
||||
if (process.platform === 'linux' && desktopSettings.vaapiVideoEncode) {
|
||||
// Enable VA-API hardware video encoding on Linux
|
||||
app.commandLine.appendSwitch('enable-features', 'VaapiVideoEncode');
|
||||
}
|
||||
|
||||
app.commandLine.appendSwitch('enable-gpu-rasterization');
|
||||
app.commandLine.appendSwitch('enable-zero-copy');
|
||||
app.commandLine.appendSwitch('enable-native-gpu-memory-buffers');
|
||||
|
||||
@@ -204,26 +204,32 @@ export function setupSystemHandlers(): void {
|
||||
});
|
||||
|
||||
ipcMain.handle('get-sources', async () => {
|
||||
const thumbnailSize = { width: 240, height: 150 };
|
||||
const [screenSources, windowSources] = await Promise.all([
|
||||
desktopCapturer.getSources({
|
||||
types: ['screen'],
|
||||
thumbnailSize
|
||||
}),
|
||||
desktopCapturer.getSources({
|
||||
types: ['window'],
|
||||
thumbnailSize,
|
||||
fetchWindowIcons: true
|
||||
})
|
||||
]);
|
||||
const sources = [...screenSources, ...windowSources];
|
||||
const uniqueSources = new Map(sources.map((source) => [source.id, source]));
|
||||
try {
|
||||
const thumbnailSize = { width: 240, height: 150 };
|
||||
const [screenSources, windowSources] = await Promise.all([
|
||||
desktopCapturer.getSources({
|
||||
types: ['screen'],
|
||||
thumbnailSize
|
||||
}),
|
||||
desktopCapturer.getSources({
|
||||
types: ['window'],
|
||||
thumbnailSize,
|
||||
fetchWindowIcons: true
|
||||
})
|
||||
]);
|
||||
const sources = [...screenSources, ...windowSources];
|
||||
const uniqueSources = new Map(sources.map((source) => [source.id, source]));
|
||||
|
||||
return [...uniqueSources.values()].map((source) => ({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
thumbnail: source.thumbnail.toDataURL()
|
||||
}));
|
||||
return [...uniqueSources.values()].map((source) => ({
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
thumbnail: source.thumbnail.toDataURL()
|
||||
}));
|
||||
} catch {
|
||||
// desktopCapturer.getSources fails on Wayland; return empty so the
|
||||
// renderer falls through to getDisplayMedia with the system picker.
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('prepare-linux-screen-share-audio-routing', async () => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
app,
|
||||
BrowserWindow,
|
||||
desktopCapturer,
|
||||
session,
|
||||
shell
|
||||
} from 'electron';
|
||||
import * as fs from 'fs';
|
||||
@@ -61,6 +63,34 @@ export async function createWindow(): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
session.defaultSession.setDisplayMediaRequestHandler(
|
||||
async (_request, respond) => {
|
||||
// On Linux/Wayland the system picker (useSystemPicker: true) handles
|
||||
// the portal. This handler is only reached if the system picker is
|
||||
// unavailable (e.g. X11 without a portal). Fall back to
|
||||
// desktopCapturer so the user still gets something.
|
||||
try {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['window', 'screen'],
|
||||
thumbnailSize: { width: 150, height: 150 }
|
||||
});
|
||||
const firstSource = sources[0];
|
||||
|
||||
if (firstSource) {
|
||||
respond({ video: firstSource });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// desktopCapturer also unavailable
|
||||
}
|
||||
|
||||
respond({});
|
||||
},
|
||||
{ useSystemPicker: true }
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env['NODE_ENV'] === 'development') {
|
||||
const devUrl = process.env['SSL'] === 'true'
|
||||
? 'https://localhost:4200'
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"electron:build:all": "npm run build:prod && npm run build:electron && electron-builder --win --mac --linux",
|
||||
"build:prod:all": "npm run build:prod && npm run build:electron && cd server && npm run build",
|
||||
"build:prod:win": "npm run build:prod:all && electron-builder --win",
|
||||
"dev": "npm run electron:full",
|
||||
"dev": "npm run build:electron && npm run electron:full",
|
||||
"dev:app": "npm run electron:dev",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "npm run format && npm run sort:props && eslint . --fix",
|
||||
|
||||
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>
|
||||
@@ -225,6 +225,7 @@ export class ScreenShareManager {
|
||||
this.activeScreenStream = await this.startWithLinuxElectronAudioRouting(shareOptions, preset);
|
||||
captureMethod = 'linux-electron';
|
||||
} catch (error) {
|
||||
this.rethrowIfScreenShareAborted(error);
|
||||
this.logger.warn('Linux Electron audio routing failed; falling back to standard capture', error);
|
||||
}
|
||||
}
|
||||
@@ -241,6 +242,7 @@ export class ScreenShareManager {
|
||||
captureMethod = null;
|
||||
}
|
||||
} catch (error) {
|
||||
this.rethrowIfScreenShareAborted(error);
|
||||
this.logger.warn('getDisplayMedia with system audio failed; falling back to Electron desktop capture', error);
|
||||
}
|
||||
}
|
||||
@@ -253,10 +255,7 @@ export class ScreenShareManager {
|
||||
shareOptions.includeSystemAudio = electronCapture.includeSystemAudio;
|
||||
captureMethod = 'electron-desktop';
|
||||
} catch (error) {
|
||||
if (this.isScreenShareSelectionAborted(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.rethrowIfScreenShareAborted(error);
|
||||
this.logger.warn('Electron desktop capture failed; falling back to getDisplayMedia', error);
|
||||
}
|
||||
}
|
||||
@@ -392,7 +391,15 @@ export class ScreenShareManager {
|
||||
}
|
||||
|
||||
private isElectronDesktopCaptureAvailable(): boolean {
|
||||
return !!this.getElectronApi()?.getSources;
|
||||
return !!this.getElectronApi()?.getSources && !this.isLinuxElectron();
|
||||
}
|
||||
|
||||
private isLinuxElectron(): boolean {
|
||||
if (!this.getElectronApi() || typeof navigator === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /linux/i.test(`${navigator.userAgent} ${navigator.platform}`);
|
||||
}
|
||||
|
||||
private isWindowsElectron(): boolean {
|
||||
@@ -725,7 +732,14 @@ export class ScreenShareManager {
|
||||
}
|
||||
|
||||
private isScreenShareSelectionAborted(error: unknown): boolean {
|
||||
return error instanceof Error && error.name === 'AbortError';
|
||||
return error instanceof Error
|
||||
&& (error.name === 'AbortError' || error.name === 'NotAllowedError');
|
||||
}
|
||||
|
||||
private rethrowIfScreenShareAborted(error: unknown): void {
|
||||
if (this.isScreenShareSelectionAborted(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private isLinuxElectronAudioRoutingSupported(): boolean {
|
||||
@@ -766,13 +780,11 @@ export class ScreenShareManager {
|
||||
throw new Error(activation.reason || 'Failed to activate Linux Electron audio routing.');
|
||||
}
|
||||
|
||||
const desktopCapture = await this.startWithElectronDesktopCapturer({
|
||||
desktopStream = await this.startWithDisplayMedia({
|
||||
...options,
|
||||
includeSystemAudio: false
|
||||
}, preset);
|
||||
|
||||
desktopStream = desktopCapture.stream;
|
||||
|
||||
const { audioTrack, captureInfo } = await this.startLinuxScreenShareMonitorTrack();
|
||||
const stream = new MediaStream([...desktopStream.getVideoTracks(), audioTrack]);
|
||||
|
||||
|
||||
101
tools/deploy-web-apps.ps1
Normal file
@@ -0,0 +1,101 @@
|
||||
[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"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
229
website/src/app/services/release.service.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
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'
|
||||
];
|
||||
|
||||
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 response = await fetch('/api/releases', {
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
this.cachedReleases = Array.isArray(data) ? data : [data];
|
||||
|
||||
return this.cachedReleases;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch releases:', error);
|
||||
|
||||
return [];
|
||||
} finally {
|
||||
this.fetchPromise = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
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"
|
||||
]
|
||||
}
|
||||