feat: Add custom slug support for links

This commit is contained in:
Jan Gleytenhoover 2025-01-25 17:32:13 +01:00
parent ab5dbc047c
commit 8e1832416b
Signed by: jank
GPG key ID: 50620ADD22CD330B
15 changed files with 54 additions and 40 deletions

View file

@ -25,7 +25,7 @@ describe('AppComponent', () => {
fixture.detectChanges(); fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement; const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain( expect(compiled.querySelector('h1')?.textContent).toContain(
'Hello, jklink', 'Hello, jklink'
); );
}); });
}); });

View file

@ -21,7 +21,7 @@ export const routes: Routes = [
canActivate: [AuthGuard], canActivate: [AuthGuard],
}, },
{ {
path: ':link', path: ':slug',
component: ViewLinkComponent, component: ViewLinkComponent,
}, },
{ {

View file

@ -1,6 +1,14 @@
<h2 mat-dialog-title>{{ data.title }}</h2> <h2 mat-dialog-title>{{ data.title }}</h2>
<mat-dialog-content>{{ data.description }}</mat-dialog-content> <mat-dialog-content>{{ data.description }}</mat-dialog-content>
<mat-dialog-actions> <mat-dialog-actions>
<button [style.backgroundColor]="'#FFFFFF'" [style.color]="'#000000'" mat-stroked-button color="warn" (click)="accept()">Yes</button> <button
[style.backgroundColor]="'#FFFFFF'"
[style.color]="'#000000'"
mat-stroked-button
color="warn"
(click)="accept()"
>
Yes
</button>
<button mat-flat-button (click)="close()">No</button> <button mat-flat-button (click)="close()">No</button>
</mat-dialog-actions> </mat-dialog-actions>

View file

@ -1,6 +1,13 @@
<div class="mx-auto container"> <div class="mx-auto container">
<app-navbar></app-navbar> <app-navbar></app-navbar>
<button [style.backgroundColor]="'#FFFFFF'" [style.color]="'#000000'" mat-stroked-button class="mt-3" color="warn" (click)="back()"> <button
[style.backgroundColor]="'#FFFFFF'"
[style.color]="'#000000'"
mat-stroked-button
class="mt-3"
color="warn"
(click)="back()"
>
Back Back
</button> </button>
<mat-card class="mt-3 p-3" appearance="outlined"> <mat-card class="mt-3 p-3" appearance="outlined">
@ -20,6 +27,11 @@
placeholder="https://kjan.de" placeholder="https://kjan.de"
/> />
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline">
<mat-error>{{ errorMessages["slug"] }}</mat-error>
<mat-label>Custom slug (optional)</mat-label>
<input formControlName="slug" matInput />
</mat-form-field>
<button mat-flat-button type="submit" (click)="submit()">Create</button> <button mat-flat-button type="submit" (click)="submit()">Create</button>
</form> </form>
</mat-card> </mat-card>

View file

@ -32,10 +32,7 @@ export class CreateLinkComponent {
public requestFailed: boolean = false; public requestFailed: boolean = false;
public errorMessages: Record<string, string> = {}; public errorMessages: Record<string, string> = {};
constructor( constructor(private linkService: LinkService, private router: Router) {}
private linkService: LinkService,
private router: Router,
) {}
private validationErrorMessages: Record<string, string> = { private validationErrorMessages: Record<string, string> = {
required: 'This field is required', required: 'This field is required',
@ -53,7 +50,7 @@ export class CreateLinkComponent {
.map( .map(
(errorKey) => (errorKey) =>
this.validationErrorMessages[errorKey] || this.validationErrorMessages[errorKey] ||
`Unknown error: ${errorKey}`, `Unknown error: ${errorKey}`
) )
.join(' '); .join(' ');
} }
@ -70,9 +67,10 @@ export class CreateLinkComponent {
link: new FormControl('', [ link: new FormControl('', [
Validators.required, Validators.required,
Validators.pattern( Validators.pattern(
/((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)/, /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)/
), ),
]), ]),
slug: new FormControl(''),
}); });
this.createLinkForm.valueChanges.subscribe(() => { this.createLinkForm.valueChanges.subscribe(() => {
this.updateErrorMessages(); this.updateErrorMessages();
@ -89,6 +87,7 @@ export class CreateLinkComponent {
.createLink({ .createLink({
name: this.createLinkForm.get('name')?.value, name: this.createLinkForm.get('name')?.value,
link: this.createLinkForm.get('link')?.value, link: this.createLinkForm.get('link')?.value,
slug: this.createLinkForm.get('slug')?.value,
}) })
.catch(() => (this.requestFailed = true)) .catch(() => (this.requestFailed = true))
.finally(() => { .finally(() => {

View file

@ -24,11 +24,11 @@
The actual rendered columns are set as a property on the row definition" --> The actual rendered columns are set as a property on the row definition" -->
<!-- Position Column --> <!-- Position Column -->
<ng-container matColumnDef="id"> <ng-container matColumnDef="slug">
<th mat-header-cell *matHeaderCellDef>ID</th> <th mat-header-cell *matHeaderCellDef>Slug</th>
<td mat-cell *matCellDef="let element"> <td mat-cell *matCellDef="let element">
<a (click)="goToLink(element)" class="link"> <a (click)="goToLink(element)" class="link">
{{ element.id }} {{ element.slug }}
</a> </a>
</td> </td>
</ng-container> </ng-container>
@ -49,11 +49,7 @@
<ng-container matColumnDef="shortLink"> <ng-container matColumnDef="shortLink">
<th mat-header-cell *matHeaderCellDef>Short link</th> <th mat-header-cell *matHeaderCellDef>Short link</th>
<td mat-cell *matCellDef="let element"> <td mat-cell *matCellDef="let element">
<button <button mat-flat-button class="my-3" (click)="copyLink(element)">
mat-flat-button
class="my-3"
(click)="copyLink(element.id)"
>
Copy short link Copy short link
</button> </button>
</td> </td>

View file

@ -27,7 +27,7 @@ import { AuthRecord } from 'pocketbase';
}) })
export class DashboardComponent { export class DashboardComponent {
public links: Link[] = []; public links: Link[] = [];
displayedColumns: string[] = ['id', 'name', 'link', 'shortLink', 'actions']; displayedColumns: string[] = ['slug', 'name', 'link', 'shortLink', 'actions'];
public user!: AuthRecord; public user!: AuthRecord;
constructor( constructor(
@ -35,15 +35,15 @@ export class DashboardComponent {
private router: Router, private router: Router,
private dialog: MatDialog, private dialog: MatDialog,
private snackBar: MatSnackBar, private snackBar: MatSnackBar,
private userService: UserService, private userService: UserService
) {} ) {}
copyLink(id: string) { copyLink(link: Link) {
navigator.clipboard.writeText(this.getShortLink(id)); navigator.clipboard.writeText(this.getShortLink(link));
} }
getShortLink(id: string): string { getShortLink(link: Link): string {
return 'https://' + window.location.hostname + '/' + id; return 'https://' + window.location.hostname + '/' + link.slug;
} }
goToLink(link: Link) { goToLink(link: Link) {
@ -91,7 +91,7 @@ export class DashboardComponent {
if (accepted) { if (accepted) {
this.linkService.deleteLink(link.id).catch(() => { this.linkService.deleteLink(link.id).catch(() => {
const errorNotification = this.snackBar.open( const errorNotification = this.snackBar.open(
'Something went wrong.', 'Something went wrong.'
); );
setTimeout(() => { setTimeout(() => {
errorNotification.dismiss(); errorNotification.dismiss();

View file

@ -35,10 +35,7 @@ export class LoginComponent {
private pb = new PocketBase(environment.POCKETBASE); private pb = new PocketBase(environment.POCKETBASE);
public errorMessages: Record<string, string> = {}; public errorMessages: Record<string, string> = {};
constructor( constructor(private router: Router, private snackBar: MatSnackBar) {}
private router: Router,
private snackBar: MatSnackBar,
) {}
private validationErrorMessages: Record<string, string> = { private validationErrorMessages: Record<string, string> = {
required: 'This field is required', required: 'This field is required',
@ -55,7 +52,7 @@ export class LoginComponent {
.map( .map(
(errorKey) => (errorKey) =>
this.validationErrorMessages[errorKey] || this.validationErrorMessages[errorKey] ||
`Unknown error: ${errorKey}`, `Unknown error: ${errorKey}`
) )
.join(' '); .join(' ');
} }
@ -105,7 +102,7 @@ export class LoginComponent {
.collection('users') .collection('users')
.authWithPassword( .authWithPassword(
this.loginForm.get('email')?.value, this.loginForm.get('email')?.value,
this.loginForm.get('password')?.value, this.loginForm.get('password')?.value
) )
.then(() => { .then(() => {
this.router.navigate(['dashboard']); this.router.navigate(['dashboard']);

View file

@ -3,4 +3,5 @@ export interface Link {
link: string; link: string;
name: string; name: string;
owner: string; owner: string;
slug: string;
} }

View file

@ -18,7 +18,7 @@ export class AuthGuard implements CanActivate {
async canActivate( async canActivate(
route: ActivatedRouteSnapshot, route: ActivatedRouteSnapshot,
state: RouterStateSnapshot, state: RouterStateSnapshot
): Promise<boolean> { ): Promise<boolean> {
const pb = new PocketBase(environment.POCKETBASE); const pb = new PocketBase(environment.POCKETBASE);
await pb await pb

View file

@ -13,8 +13,8 @@ export class LinkService {
return this.pb.collection<Link>('links').getFullList<Link>(); return this.pb.collection<Link>('links').getFullList<Link>();
} }
getLink(id: string): Promise<Link> { getLink(slug: string): Promise<Link> {
return this.pb.collection('links').getOne<Link>(id); return this.pb.collection('links').getFirstListItem(`slug="${slug}"`);
} }
deleteLink(id: string): Promise<boolean> { deleteLink(id: string): Promise<boolean> {
@ -26,6 +26,7 @@ export class LinkService {
name: link.name, name: link.name,
link: link.link, link: link.link,
owner: this.pb.authStore.record?.id, owner: this.pb.authStore.record?.id,
slug: link.slug,
}); });
} }
} }

View file

@ -1,6 +1,6 @@
import { Injectable } from "@angular/core"; import { Injectable } from '@angular/core';
import PocketBase, { AuthRecord } from 'pocketbase'; import PocketBase, { AuthRecord } from 'pocketbase';
import { environment } from "../../environments/environment"; import { environment } from '../../environments/environment';
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',

View file

@ -12,12 +12,12 @@ export class ViewLinkComponent {
constructor( constructor(
private linkService: LinkService, private linkService: LinkService,
private route: ActivatedRoute, private route: ActivatedRoute,
private router: Router, private router: Router
) {} ) {}
ngOnInit(): void { ngOnInit(): void {
this.linkService this.linkService
.getLink(this.route.snapshot.params['link']) .getLink(this.route.snapshot.params['slug'])
.then((link) => { .then((link) => {
window.location.href = link.link; window.location.href = link.link;
}); });

View file

@ -1,4 +1,4 @@
<!doctype html> <!DOCTYPE html>
<html class="h-full" lang="en" data-theme="light"> <html class="h-full" lang="en" data-theme="light">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />

View file

@ -3,5 +3,5 @@ import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component'; import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig).catch((err) => bootstrapApplication(AppComponent, appConfig).catch((err) =>
console.error(err), console.error(err)
); );