diff --git a/src/app/app.component.html b/src/app/app.component.html index a81f3e2..97ecfb4 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -25,6 +25,9 @@
  • {{'register_account' | transloco}}
  • +
  • + {{'transfer' | transloco}} +
  • {{'news' | transloco}}
  • @@ -109,6 +112,17 @@

    { +
    +

    {{'other_links' | transloco}}

    + +

    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 @@ +
    +
    +
    +

    + +

    +
    +
    +
    + +
    +
    +
    +
    +

    +
    +
    + + +
    + @if (form.value.apiKeyValidated === false) { +

    {{'invalid_api_key' | transloco}}

    + } + @if (currentUser()) { +

    {{'current_user' | transloco:{user: currentUser()!.username} }}

    + } + +
    + +
    + +
    + + +
    + @if (form.value.targetUserValidated === false) { +

    {{'invalid_target_user' | transloco}}

    + } + +
    + + +
    + @if (form.value.kudosAmountValidated === false) { +

    {{'transfer.too_many_kudos' | transloco}}

    + } + + @if (sentSuccessfully() !== null) { + @if (sentSuccessfully()) { +

    {{'transfer.success' | transloco}}

    + } @else { +

    {{'transfer.error' | transloco}}

    + } + } + +
    +
    +
    +
    +
    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);