diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index 8a67128..44c476a 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -29,4 +29,8 @@ export const routes: Routes = [
path: 'terms',
loadComponent: () => import('./pages/terms/terms.component').then(c => c.TermsComponent),
},
+ {
+ path: 'transfer-v2',
+ loadComponent: () => import('./pages/transfer/transfer.component').then(c => c.TransferComponent),
+ },
];
diff --git a/src/app/components/toggle-checkbox/toggle-checkbox.component.html b/src/app/components/toggle-checkbox/toggle-checkbox.component.html
new file mode 100644
index 0000000..8ef0055
--- /dev/null
+++ b/src/app/components/toggle-checkbox/toggle-checkbox.component.html
@@ -0,0 +1,6 @@
+
diff --git a/src/app/components/toggle-checkbox/toggle-checkbox.component.scss b/src/app/components/toggle-checkbox/toggle-checkbox.component.scss
new file mode 100644
index 0000000..578ef9b
--- /dev/null
+++ b/src/app/components/toggle-checkbox/toggle-checkbox.component.scss
@@ -0,0 +1,88 @@
+@import "../../../styles/colors";
+
+$small: 576px;
+@mixin mediaMax($maxWidth) {
+ @media screen and (max-width: $maxWidth) {
+ @content
+ }
+}
+$mainTextColor: $darkTextColor;
+$shadowColor: rgba(0, 0, 0, 0.72);
+$successColor: #1c64f2;
+
+.toggle {
+ cursor: pointer;
+ display: inline-block;
+}
+
+.toggle-switch {
+ display: inline-block;
+ background: #ccc;
+ border-radius: 16px;
+ width: 48px;
+ height: 26px;
+ position: relative;
+ vertical-align: middle;
+ transition: background 0.25s;
+
+ @include mediaMax($small) {
+ width: 38px;
+ height: 22px;
+ }
+
+ &:before,
+ &:after {
+ content: "";
+ }
+
+ &:before {
+ display: block;
+ background: linear-gradient(to bottom, $mainTextColor 0%, lighten($mainTextColor, 10%) 100%);
+ border-radius: 50%;
+ box-shadow: 0 0 0 1px rgba(red($shadowColor), green($shadowColor), blue($shadowColor), 0.15);
+ width: 20px;
+ height: 20px;
+ position: absolute;
+ top: 3px;
+ left: 3px;
+ transition: left 0.25s;
+
+ @include mediaMax($small) {
+ width: 16px;
+ height: 16px;
+ }
+ }
+
+ .toggle:hover &:before {
+ background: linear-gradient(to bottom, $mainTextColor 0%, $mainTextColor 100%);
+ }
+
+ .toggle-checkbox:checked + & {
+ background: $successColor;
+
+ &:before {
+ left: 24px;
+
+ @include mediaMax($small) {
+ left: 18px;
+ }
+ }
+ }
+}
+
+.toggle-checkbox {
+ position: absolute;
+ visibility: hidden;
+}
+
+.toggle-label {
+ margin-left: 5px;
+ position: relative;
+ top: 2px;
+ font-size: 14px;
+ margin-right: 4px;
+
+ @include mediaMax($small) {
+ margin-left: 8px;
+ }
+}
diff --git a/src/app/components/toggle-checkbox/toggle-checkbox.component.ts b/src/app/components/toggle-checkbox/toggle-checkbox.component.ts
new file mode 100644
index 0000000..b63cb89
--- /dev/null
+++ b/src/app/components/toggle-checkbox/toggle-checkbox.component.ts
@@ -0,0 +1,70 @@
+import {Component, effect, input, OnInit, output, signal, WritableSignal} from '@angular/core';
+import {ControlValueAccessor, NG_VALUE_ACCESSOR} from "@angular/forms";
+import {OnChange, OnTouched} from "../../types/value-accessor";
+
+@Component({
+ selector: 'app-toggle-checkbox',
+ standalone: true,
+ imports: [],
+ templateUrl: './toggle-checkbox.component.html',
+ styleUrl: './toggle-checkbox.component.scss',
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ multi: true,
+ useExisting: ToggleCheckboxComponent,
+ },
+ ],
+})
+export class ToggleCheckboxComponent implements ControlValueAccessor, OnInit {
+ private onChange: WritableSignal
| null> = signal(null);
+ private onTouched: WritableSignal = signal(null);
+
+ private formMode = signal(true);
+
+ public initialValue = input(null);
+
+ public value = signal(false);
+ public disabled = signal(false);
+
+ public description = input.required();
+ public random = signal(Math.random());
+
+ public valueChanged = output();
+
+ constructor() {
+ effect(() => {
+ if (this.formMode()) {
+ if (this.onChange() === null) {
+ return;
+ }
+ (this.onChange()!)(this.value());
+ } else {
+ this.valueChanged.emit(this.value());
+ }
+ });
+ }
+
+ public async ngOnInit(): Promise {
+ if (this.initialValue() !== null) {
+ this.formMode.set(false);
+ this.value.set(this.initialValue()!);
+ }
+ }
+
+ public writeValue(value: boolean): void {
+ this.value.set(value);
+ }
+
+ public registerOnChange(fn: OnChange): void {
+ this.onChange.set(fn);
+ }
+
+ public registerOnTouched(fn: OnTouched): void {
+ this.onTouched.set(fn);
+ }
+
+ public setDisabledState(isDisabled: boolean): void {
+ this.disabled.set(isDisabled);
+ }
+}
diff --git a/src/app/pages/transfer/transfer.component.html b/src/app/pages/transfer/transfer.component.html
new file mode 100644
index 0000000..6dc4543
--- /dev/null
+++ b/src/app/pages/transfer/transfer.component.html
@@ -0,0 +1,68 @@
+
+
+
diff --git a/src/app/pages/transfer/transfer.component.scss b/src/app/pages/transfer/transfer.component.scss
new file mode 100644
index 0000000..e69de29
diff --git a/src/app/pages/transfer/transfer.component.ts b/src/app/pages/transfer/transfer.component.ts
new file mode 100644
index 0000000..7bbb3ff
--- /dev/null
+++ b/src/app/pages/transfer/transfer.component.ts
@@ -0,0 +1,156 @@
+import {Component, computed, OnDestroy, OnInit, signal} from '@angular/core';
+import {TranslocoMarkupComponent} from "ngx-transloco-markup";
+import {Title} from "@angular/platform-browser";
+import {toPromise} from "../../types/resolvable";
+import {TranslatorService} from "../../services/translator.service";
+import {FooterColorService} from "../../services/footer-color.service";
+import {InlineSvgComponent} from "../../components/inline-svg/inline-svg.component";
+import {JsonPipe, KeyValuePipe} from "@angular/common";
+import {TranslocoPipe} from "@jsverse/transloco";
+import {ToggleCheckboxComponent} from "../../components/toggle-checkbox/toggle-checkbox.component";
+import {FormControl, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
+import {DatabaseService, StorageType} from "../../services/database.service";
+import {debounceTime} from "rxjs";
+import {Subscriptions} from "../../helper/subscriptions";
+import {AiHordeService} from "../../services/ai-horde.service";
+import {HordeUser} from "../../types/horde-user";
+
+@Component({
+ selector: 'app-transfer',
+ standalone: true,
+ imports: [
+ TranslocoMarkupComponent,
+ InlineSvgComponent,
+ KeyValuePipe,
+ TranslocoPipe,
+ ToggleCheckboxComponent,
+ ReactiveFormsModule,
+ JsonPipe
+ ],
+ templateUrl: './transfer.component.html',
+ styleUrl: './transfer.component.scss'
+})
+export class TransferComponent implements OnInit, OnDestroy {
+ private subscriptions = new Subscriptions();
+
+ private exampleUsers = signal([
+ 'db0#1',
+ 'Tazlin#6572',
+ 'Rikudou#185676',
+ ]);
+
+ public exampleUser = computed(() => {
+ return this.exampleUsers()[Math.floor(Math.random()*this.exampleUsers().length)];
+ });
+ public currentUser = signal(null);
+ public sentSuccessfully = signal(null);
+ public maximumKudos = computed(() => {
+ if (!this.currentUser()) {
+ return null;
+ }
+
+ return this.currentUser()!.kudos;
+ })
+
+ public form = new FormGroup({
+ apiKey: new FormControl('', [Validators.required]),
+ remember: new FormControl(false),
+ targetUser: new FormControl('', [Validators.required]),
+ kudosAmount: new FormControl(1, [Validators.required, Validators.min(1)]),
+
+ apiKeyValidated: new FormControl(null, [Validators.requiredTrue]),
+ targetUserValidated: new FormControl(null, [Validators.requiredTrue]),
+ kudosAmountValidated: new FormControl(null, [Validators.requiredTrue]),
+ });
+
+ constructor(
+ private readonly title: Title,
+ private readonly translator: TranslatorService,
+ private readonly footerColor: FooterColorService,
+ private readonly database: DatabaseService,
+ private readonly aiHorde: AiHordeService,
+ ) {
+ }
+
+ public async ngOnInit(): Promise {
+ this.title.setTitle(await toPromise(this.translator.get('transfer')) + ' | ' + await toPromise(this.translator.get('app_title')));
+ this.footerColor.dark.set(true);
+
+ const remember = this.database.get('remember_api_key', true);
+ const apiKey = this.database.get('api_key', remember ? StorageType.Permanent : StorageType.Session);
+
+ this.subscriptions.add(this.form.controls.apiKey.valueChanges.subscribe(() => {
+ this.form.patchValue({apiKeyValidated: null});
+ }));
+ this.subscriptions.add(this.form.controls.targetUser.valueChanges.subscribe(() => {
+ this.form.patchValue({targetUserValidated: null});
+ }));
+ this.subscriptions.add(this.form.controls.kudosAmount.valueChanges.subscribe(kudosAmount => {
+ kudosAmount ??= 0;
+ this.form.patchValue({kudosAmountValidated: this.maximumKudos() !== null && kudosAmount <= this.maximumKudos()!});
+ }));
+ this.subscriptions.add(this.form.controls.apiKey.valueChanges.pipe(
+ debounceTime(500)
+ ).subscribe(async apiKey => {
+ if (!apiKey) {
+ return;
+ }
+
+ this.currentUser.set(await toPromise(this.aiHorde.getUserByApiKey(apiKey)));
+ this.form.patchValue({apiKeyValidated: this.currentUser() !== null});
+ }));
+ this.subscriptions.add(this.form.controls.targetUser.valueChanges.pipe(
+ debounceTime(500)
+ ).subscribe(async targetUser => {
+ if (!targetUser) {
+ return;
+ }
+
+ const parts = targetUser.split('#');
+ if (parts.length !== 2) {
+ this.form.patchValue({targetUserValidated: false});
+ return;
+ }
+
+ const id = Number(parts[1]);
+ const user = await toPromise(this.aiHorde.getUserById(id));
+ this.form.patchValue({targetUserValidated: user !== null && targetUser === user.username});
+ }));
+
+ this.form.patchValue({
+ remember: remember,
+ });
+ if (apiKey) {
+ this.form.patchValue({
+ apiKey: apiKey,
+ });
+ }
+
+ this.subscriptions.add(this.form.valueChanges.pipe(
+ debounceTime(300)
+ ).subscribe(value => {
+ this.database.store('remember_api_key', value.remember ?? false);
+ this.database.store('api_key', value.apiKey ?? '', value.remember ? StorageType.Permanent : StorageType.Session);
+
+ this.sentSuccessfully.set(null);
+ }));
+ }
+
+ public ngOnDestroy(): void {
+ this.subscriptions.unsubscribe();
+ }
+
+ public async transfer(): Promise {
+ this.sentSuccessfully.set(null);
+ if (!this.form.valid) {
+ return;
+ }
+
+ const success = await toPromise(this.aiHorde.transferKudos(
+ this.form.value.apiKey!,
+ this.form.value.targetUser!,
+ this.form.value.kudosAmount!,
+ ));
+ this.sentSuccessfully.set(success);
+ }
+}
diff --git a/src/app/services/ai-horde.service.ts b/src/app/services/ai-horde.service.ts
index 6ab26fc..64e2a06 100644
--- a/src/app/services/ai-horde.service.ts
+++ b/src/app/services/ai-horde.service.ts
@@ -1,12 +1,13 @@
import {Injectable} from '@angular/core';
import {HttpClient} from "@angular/common/http";
-import {map, Observable, of} from "rxjs";
+import {catchError, map, Observable, of} from "rxjs";
import {ImageTotalStats} from "../types/image-total-stats";
import {HordePerformance} from "../types/horde-performance";
import {TextTotalStats} from "../types/text-total-stats";
import {NewsItem} from "../types/news.types";
import {SingleInterrogationStatPoint} from "../types/single-interrogation-stat-point";
import {HtmlHordeDocument} from "../types/horde-document";
+import {HordeUser} from "../types/horde-user";
@Injectable({
providedIn: 'root'
@@ -76,4 +77,34 @@ export class AiHordeService {
}))
);
}
+
+ public getUserByApiKey(apiKey: string): Observable {
+ return this.httpClient.get('https://aihorde.net/api/v2/find_user', {
+ headers: {
+ apikey: apiKey,
+ }
+ }).pipe(
+ catchError(() => of(null)),
+ );
+ }
+
+ public getUserById(id: number): Observable {
+ return this.httpClient.get(`https://aihorde.net/api/v2/users/${id}`).pipe(
+ catchError(() => of(null)),
+ );
+ }
+
+ public transferKudos(apiKey: string, targetUser: string, amount: number): Observable {
+ return this.httpClient.post('https://aihorde.net/api/v2/kudos/transfer', {
+ username: targetUser,
+ amount: amount,
+ }, {
+ headers: {
+ apikey: apiKey,
+ },
+ }).pipe(
+ map(() => true),
+ catchError(() => of(false)),
+ );
+ }
}
diff --git a/src/app/services/database.service.ts b/src/app/services/database.service.ts
new file mode 100644
index 0000000..e8b9132
--- /dev/null
+++ b/src/app/services/database.service.ts
@@ -0,0 +1,105 @@
+import {Injectable} from '@angular/core';
+
+export enum StorageType {
+ Session,
+ Permanent,
+ Ephemeral,
+}
+
+@Injectable({
+ providedIn: 'root'
+})
+export class DatabaseService {
+ private values: { [key: string]: any } = {};
+
+ public store(key: string, value: any): void;
+ public store(key: string, value: any, storageType: StorageType): void;
+ public store(key: string, value: any, storageType: StorageType = StorageType.Permanent): void {
+ if (typeof window === 'undefined') {
+ storageType = StorageType.Ephemeral;
+ }
+
+ switch (storageType) {
+ case StorageType.Ephemeral:
+ this.storeEphemeral(key, value);
+ break;
+ case StorageType.Session:
+ this.storeSession(key, value);
+ break;
+ case StorageType.Permanent:
+ this.storePermanent(key, value);
+ break;
+ }
+ }
+
+ public get(key: string): any;
+ public get(key: string, defaultValue: T extends StorageType ? never : T): T;
+ public get(key: string, storageType: StorageType): any;
+ public get(key: string, defaultValue: T, storageType: StorageType): T;
+ public get(key: string, defaultValueOrStorageType?: T | StorageType, storageType?: StorageType): any {
+ const hasDefault: boolean = typeof defaultValueOrStorageType !== 'undefined'
+ && (typeof defaultValueOrStorageType !== 'number' || !Object.values(StorageType).includes(defaultValueOrStorageType));
+
+ if (typeof window === 'undefined') {
+ storageType = StorageType.Ephemeral;
+ }
+ if (storageType === undefined) {
+ if (typeof defaultValueOrStorageType === 'number' && Object.values(StorageType).includes(defaultValueOrStorageType)) {
+ storageType = defaultValueOrStorageType;
+ } else {
+ storageType = StorageType.Permanent;
+ }
+ }
+
+ const defaultValue: T | null = hasDefault ? defaultValueOrStorageType : null;
+
+ let value: T | null;
+ switch (storageType) {
+ case StorageType.Ephemeral:
+ value = this.getEphemeral(key) ?? defaultValue;
+ break;
+ case StorageType.Session:
+ value = this.getSession(key) ?? defaultValue;
+ break;
+ case StorageType.Permanent:
+ value = this.getPermanent(key) ?? defaultValue;
+ break;
+ }
+
+ return value;
+ }
+
+ private storeEphemeral(key: string, value: any): void {
+ this.values[key] = value;
+ }
+
+ private storeSession(key: string, value: any): void {
+ sessionStorage.setItem(key, JSON.stringify(value));
+ }
+
+ private storePermanent(key: string, value: any): void {
+ localStorage.setItem(key, JSON.stringify(value));
+ }
+
+ private getEphemeral(key: string): T | null {
+ return this.values[key] ?? null;
+ }
+
+ private getSession(key: string): T | null {
+ const value = sessionStorage.getItem(key);
+ if (value === null) {
+ return null;
+ }
+
+ return JSON.parse(value);
+ }
+
+ private getPermanent(key: string): T | null {
+ const value = localStorage.getItem(key);
+ if (value === null) {
+ return null;
+ }
+
+ return JSON.parse(value);
+ }
+}
diff --git a/src/app/types/horde-user.ts b/src/app/types/horde-user.ts
new file mode 100644
index 0000000..692eaa3
--- /dev/null
+++ b/src/app/types/horde-user.ts
@@ -0,0 +1,5 @@
+export interface HordeUser {
+ username: string;
+ id: number;
+ kudos: number;
+}
diff --git a/src/app/types/value-accessor.ts b/src/app/types/value-accessor.ts
new file mode 100644
index 0000000..5d9aa18
--- /dev/null
+++ b/src/app/types/value-accessor.ts
@@ -0,0 +1,2 @@
+export type OnChange = (value: T) => void;
+export type OnTouched = () => void;
diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json
index 822dd6f..dfe8673 100644
--- a/src/assets/i18n/en.json
+++ b/src/assets/i18n/en.json
@@ -70,5 +70,22 @@
"guis.desktop": "Desktop",
"tools": "Tools",
"register_account": "Register an account",
- "lemmy": "Lemmy"
+ "lemmy": "Lemmy",
+ "transfer": "Transfer kudos",
+ "transfer.visit_old": "If you would like to log in using one of the account providers (Google, Discord, Github), you might need to use [link:oldUrl]the old version of the transfer page[/link].",
+ "transfer.source_api_key": "Your API key",
+ "transfer.target_user": "Unique user ID",
+ "transfer.target_user.description": "For example: {{exampleUser}}",
+ "transfer.source_api_key.description": "Type the API key that belongs to your user",
+ "transfer.source_api_key.remember": "Remember the API key?",
+ "other_links": "Other links",
+ "current_user": "Current user: {{user}}",
+ "invalid_api_key": "The api key is not valid",
+ "invalid_target_user": "The user does not exist",
+ "transfer.kudos_amount": "Kudos amount",
+ "transfer.kudos_amount.description": "How many kudos do you want {{user}} to receive",
+ "transfer.kudos_amount.description.user_placeholder": "the user",
+ "transfer.success": "The kudos have been successfully transferred!",
+ "transfer.error": "There was an error while transferring the kudos, please try again later.",
+ "transfer.too_many_kudos": "Eh, you don't have that many kudos"
}
diff --git a/src/styles.scss b/src/styles.scss
index 95ce35b..9b3d0ec 100644
--- a/src/styles.scss
+++ b/src/styles.scss
@@ -87,3 +87,11 @@ h3, p, .underline-links {
margin-bottom: 30px;
}
}
+
+.bg-gray-600 {
+ background-color: $bgGray600;
+}
+
+.text-red {
+ color: red;
+}
diff --git a/src/styles/_colors.scss b/src/styles/_colors.scss
index b0a14e4..2d9585b 100644
--- a/src/styles/_colors.scss
+++ b/src/styles/_colors.scss
@@ -2,3 +2,5 @@ $darkTextColor: rgb(156, 163, 175);
$lightTextColor: rgb(107, 114, 128);
$purpleTextColor600: rgb(126, 58, 242);
$purpleTextColor500: rgb(144, 97, 249);
+$bgGray600: rgb(75, 85, 99);
+$bgGray700: rgb(55, 65, 81);