basic feed scroll in place

This commit is contained in:
TopchetoEU 2023-06-30 15:57:17 +03:00
parent ebe52b3aad
commit 650ae22418
19 changed files with 210 additions and 89 deletions

View File

@ -25,7 +25,7 @@ export default class ImageRouter extends AppRouter {
} }
@rest('GET', '/') @rest('GET', '/')
async get(@schema('uuid') id: UUID, @jwt(v => v.salt, false) @auth() jwt?: JWTPayload) { async get(@schema('uuid') id: UUID, @jwt('salt', false) @auth() jwt?: JWTPayload) {
const image = await this.db.images.findOne({ _id: new UUID(id) }); const image = await this.db.images.findOne({ _id: new UUID(id) });
if ( if (
@ -36,7 +36,7 @@ export default class ImageRouter extends AppRouter {
return ImageRouter.deserialize(image); return ImageRouter.deserialize(image);
} }
@rest('GET', '/img/:id') @rest('GET', '/img/:id')
async file(id: string, @jwt(v => v.salt, false) @auth() jwt?: JWTPayload) { async file(id: string, @jwt('salt', false) @auth() jwt?: JWTPayload) {
try { try {
const start = await Deno.realPath("images"); const start = await Deno.realPath("images");
const file = await Deno.realPath(`images/${id}`); const file = await Deno.realPath(`images/${id}`);
@ -52,12 +52,23 @@ export default class ImageRouter extends AppRouter {
.body((await Deno.open(`images/${id}`)).readable) .body((await Deno.open(`images/${id}`)).readable)
.contentType(id.split('.')[1]); .contentType(id.split('.')[1]);
} }
@rest('GET', '/feed') @rest('GET')
async self(@page() page: Page) { async feed(@page() page: Page) {
const res = await page.apply(this.db.images.find({ visibility: Visibility.Public })).sort({ created: -1 }).toArray(); const res = await page.apply(this.db.images.find({ visibility: Visibility.Public })).sort({ created: -1 }).toArray();
if (!res) throw new HttpError('User not found.'); if (!res) throw new HttpError('User not found.');
return res.map(v => ImageRouter.deserialize(v)); return res.map(v => ImageRouter.deserialize(v));
} }
@rest('GET', '/feed/:username')
async userFeed(username: string, @page() page: Page, @jwt('salt', false) @auth() jwt?: JWTPayload) {
const user = await this.db.users.findOne({ username });
if (user === undefined) throw new HttpError("User not found.");
let cursor;
if (user.username === jwt?.name) cursor = this.db.images.find({ _id: { $in: user.images } });
else cursor = this.db.images.find({ _id: { $in: user.images }, visibility: Visibility.Public });
return (await page.apply(cursor.sort({ created: -1 })).toArray()).map(v => ImageRouter.deserialize(v));
}
@rest('POST', '/upload') @rest('POST', '/upload')
async upload(@body() body: Blob, @headers() headers: Headers, @jwt(v => v.salt, true) @auth() jwt: JWTPayload) { async upload(@body() body: Blob, @headers() headers: Headers, @jwt(v => v.salt, true) @auth() jwt: JWTPayload) {
@ -94,7 +105,7 @@ export default class ImageRouter extends AppRouter {
// Clean up request // Clean up request
const req = await convert(rawReq, { name: 'string?', visibility: 'number?' }); const req = await convert(rawReq, { name: 'string?', visibility: 'number?' });
req.name ??= new UUID().toString(); req.name ??= new UUID().toString();
req.visibility = 0; req.visibility ??= 0;
if (req.visibility < 0 || req.visibility > 2) throw new HttpError("body.visibility: Must be 0, 1, or 2"); if (req.visibility < 0 || req.visibility > 2) throw new HttpError("body.visibility: Must be 0, 1, or 2");
const id = new UUID(); const id = new UUID();
// Create file // Create file

View File

@ -31,11 +31,11 @@ export default class UserRouter extends AppRouter {
} }
@rest('GET', '/') @rest('GET', '/')
async get(@schema('string') username: string, @jwt('salt', true) @auth() jwt: JWTPayload) { async get(@schema('string') username: string, @jwt('salt', false) @auth() jwt?: JWTPayload) {
const res = await this.db.users.findOne({ username }); const res = await this.db.users.findOne({ username });
if (res === undefined) throw new HttpError('User not found.'); if (res === undefined) throw new HttpError('User not found.');
return this.deserialize(res, jwt.name === username); return this.deserialize(res, jwt?.name === username);
} }
@rest('GET', '/self') @rest('GET', '/self')
async self(@jwt('salt', true) @auth() auth: JWTPayload) { async self(@jwt('salt', true) @auth() auth: JWTPayload) {

View File

@ -109,7 +109,6 @@ export default class Router {
for await (const req of Deno.serveHttp(conn)) { for await (const req of Deno.serveHttp(conn)) {
console.log(req.request.url); console.log(req.request.url);
const r = await this.handle(RestRequest.fromMessage(req)); const r = await this.handle(RestRequest.fromMessage(req));
console.log(req.request.url);
if (r) req.respondWith(r.toFetchResponse()); if (r) req.respondWith(r.toFetchResponse());
} }
})(); })();

6
frontend/.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"deno.enable": true,
"deno.unstable": true,
"deno.enablePaths": [ "backend" ],
"typescript.tsdk": "node_modules\\typescript\\lib"
}

View File

@ -12,6 +12,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="content"> <div class="content" #container (scroll)="onScroll(container)">
<router-outlet #o="outlet"></router-outlet> <router-outlet #o="outlet"></router-outlet>
</div> </div>

View File

@ -1,5 +1,6 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { UsersService } from './services/users.service'; import { UsersService } from './services/users.service';
import { ScrollService } from './services/scroll.service';
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
@ -7,5 +8,12 @@ import { UsersService } from './services/users.service';
styleUrls: ['./app.component.scss'] styleUrls: ['./app.component.scss']
}) })
export class AppComponent { export class AppComponent {
constructor(public users: UsersService) {} public onScroll(el: HTMLDivElement) {
if (el.scrollTop + el.clientHeight * 2 >= el.scrollHeight) this.scroll.signal(el.scrollHeight);
}
constructor(
public users: UsersService,
public scroll: ScrollService
) { }
} }

View File

@ -10,6 +10,7 @@ import { ReactiveFormsModule } from '@angular/forms';
import { PageSignupComponent } from './page-signup/page-signup.component'; import { PageSignupComponent } from './page-signup/page-signup.component';
import { PageUploadComponent } from './page-upload/page-upload.component'; import { PageUploadComponent } from './page-upload/page-upload.component';
import { PageUserComponent } from './page-user/page-user.component'; import { PageUserComponent } from './page-user/page-user.component';
import { ImagesComponent } from './images/images.component';
@NgModule({ @NgModule({
declarations: [ declarations: [
@ -18,7 +19,8 @@ import { PageUserComponent } from './page-user/page-user.component';
PageLoginComponent, PageLoginComponent,
PageSignupComponent, PageSignupComponent,
PageUploadComponent, PageUploadComponent,
PageUserComponent PageUserComponent,
ImagesComponent
], ],
imports: [ imports: [
BrowserModule, BrowserModule,

View File

@ -0,0 +1,9 @@
<div class="images">
<div class="image" *ngFor="let img of images">
<img [src]="environment.apiURL + '/images/img/' + img.file">
<h3 class="image-name">{{img.name}}</h3>
<h5 class="image-author">By {{img.author}}</h5>
</div>
</div>
<h3 *ngIf="ended">No more photos :(</h3>

View File

@ -0,0 +1,55 @@
.images {
display: block;
width: min(100%, 85rem);
margin: auto;
gap: 5rem;
align-items: stretch;
box-sizing: border-box;
padding: 0 1rem;
text-align: center;
.image {
user-select: none;
img {
pointer-events: none;
height: 15rem;
display: block;
}
width: min-content;
border-radius: 1rem;
box-shadow: .25rem .25rem 1rem -.5rem #000;
padding: 1rem;
box-sizing: border-box;
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 1rem;
margin: 1rem;
p, h3, h5 {
padding: 0;
margin: 0;
}
h3, h5 {
text-align: center;
}
p {
text-align: justify;
}
}
}
@media screen and (width < 50rem) {
.images {
display: flex;
flex-direction: column;
gap: 5rem;
align-items: stretch;
.image {
width: 100%;
}
}
}

View File

@ -0,0 +1,42 @@
import { Component, EventEmitter, Input, Output, ViewChild } from '@angular/core';
import { environment } from 'src/environments/environment';
import { Image, ImagesService } from '../services/images.service';
import { ScrollService } from '../services/scroll.service';
@Component({
selector: 'app-images',
templateUrl: './images.component.html',
styleUrls: ['./images.component.scss']
})
export class ImagesComponent {
@Input() public n = 10;
@Input() public feedFunc: (n: number, i: number) => Promise<Image[]> = async () => [];
public ended = false;
public images: Image[] = [];
public environment = environment;
public i = 0;
private sub: () => void;
public async next() {
const res = await this.feedFunc(this.n, this.i);
if (res.length === 0) {
this.ended = true;
this.sub();
return false;
}
else {
this.images.push(...res);
this.i++;
}
return true;
}
public constructor(
public scroll: ScrollService
) {
this.sub = scroll.endReached.subscribe(() => {
this.next();
}).unsubscribe;
}
}

View File

@ -1,10 +1,6 @@
<button routerLink="/upload" *ngIf="users.$user()">Upload image</button> <button routerLink="/upload" *ngIf="users.$user()">Upload image</button>
<!-- <button routerLink="/projects" *ngIf="users.$user()">Към проектите</button> --> <!-- <button routerLink="/projects" *ngIf="users.$user()">Към проектите</button> -->
<div class="images"> <app-images [feedFunc]="feed.bind(this)" #imgs>
<div class="image" *ngFor="let img of images">
<img [src]="environment.apiURL + '/images/img/' + img.file"> </app-images>
<h3 class="image-name">{{img.name}}</h3>
<h5 class="image-author">By {{img.author}}</h5>
</div>
</div>

View File

@ -12,59 +12,6 @@
margin: 0; margin: 0;
} }
} }
.images {
margin-top: 5rem;
width: 85rem;
max-width: 100%;
gap: 5rem;
align-items: stretch;
box-sizing: border-box;
padding: 0 1rem;
text-align: center;
.image {
user-select: none;
img {
pointer-events: none;
height: 15rem;
display: block;
}
width: min-content;
border-radius: 1rem;
box-shadow: .25rem .25rem 1rem -.5rem #000;
padding: 1rem;
box-sizing: border-box;
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 1rem;
margin: 1rem;
p, h3, h5 {
padding: 0;
margin: 0;
}
h3, h5 {
text-align: center;
}
p {
text-align: justify;
}
}
}
@media screen and (width < 50rem) {
.images {
margin-top: 5rem;
display: flex;
flex-direction: column;
gap: 5rem;
align-items: stretch;
.image {
width: 100%;
}
}
}
:host { :host {
margin-top: 5rem; margin-top: 5rem;

View File

@ -1,21 +1,31 @@
import { Component } from '@angular/core'; import { AfterViewInit, Component, ViewChild } from '@angular/core';
import { UsersService } from '../services/users.service'; import { UsersService } from '../services/users.service';
import { Image, ImagesService } from '../services/images.service'; import { Image, ImagesService } from '../services/images.service';
import { environment } from 'src/environments/environment'; import { environment } from 'src/environments/environment';
import { ImagesComponent } from '../images/images.component';
import { ScrollService } from '../services/scroll.service';
@Component({ @Component({
selector: 'app-page-home', selector: 'app-page-home',
templateUrl: './page-home.component.html', templateUrl: './page-home.component.html',
styleUrls: ['./page-home.component.scss'] styleUrls: ['./page-home.component.scss']
}) })
export class PageHomeComponent { export class PageHomeComponent implements AfterViewInit {
public images: Image[] = []; @ViewChild("imgs")
public environment = environment; public imagesEl!: ImagesComponent;
public feed(n: number, i: number) {
return this.images.feed({ n, i });
}
public ngAfterViewInit(): void {
this.imagesEl.next();
}
public constructor( public constructor(
images: ImagesService,
public users: UsersService, public users: UsersService,
public images: ImagesService,
) { ) {
images.feed().then(v => this.images = v);
} }
} }

View File

@ -21,7 +21,8 @@ export class PageUploadComponent {
public async submit() { public async submit() {
await this.images.upload({ await this.images.upload({
name: this.form.value.name ?? '' name: this.form.value.name ?? undefined,
visibility: this.form.value.visibility ?? undefined,
}, this.form.value.file!); }, this.form.value.file!);
this.router.navigateByUrl('/'); this.router.navigateByUrl('/');
} }

View File

@ -1 +1,5 @@
<p>page-user works!</p> <h3>{{user.username}}</h3>
<app-images [feedFunc]="feed.bind(this)" #imgs>
</app-images>

View File

@ -1,6 +1,9 @@
import { Component } from '@angular/core'; import { Component, ViewChild } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { User, UsersService } from '../services/users.service'; import { User, UsersService } from '../services/users.service';
import { Image, ImagesService } from '../services/images.service';
import { environment } from 'src/environments/environment';
import { ImagesComponent } from '../images/images.component';
@Component({ @Component({
selector: 'app-page-user', selector: 'app-page-user',
@ -8,14 +11,22 @@ import { User, UsersService } from '../services/users.service';
styleUrls: ['./page-user.component.scss'] styleUrls: ['./page-user.component.scss']
}) })
export class PageUserComponent { export class PageUserComponent {
public user: User = { images: [], username: 'loading...' }; @ViewChild("imgs")
public imagesEl!: ImagesComponent;
public user: User = { username: 'loading...' };
public feed(n: number, i: number) {
return this.images.userFeed(this.user.username, { n, i });
}
public constructor( public constructor(
private route: ActivatedRoute, route: ActivatedRoute,
private users: UsersService users: UsersService,
private images: ImagesService,
) { ) {
route.paramMap.subscribe(async v => { route.paramMap.subscribe(async v => {
this.user = await users.get(v.get('name')!); this.user = await users.get(v.get('name')!);
this.imagesEl.next();
}); });
} }
} }

View File

@ -21,6 +21,7 @@ export interface CreateImageBody {
name?: string; name?: string;
visibility?: Visibility; visibility?: Visibility;
} }
export type Page = {} | { n: number; } | { n: number; i: number; };
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -34,11 +35,15 @@ export class ImagesService {
params: { id } params: { id }
})); }));
} }
public async feed(n?: number, i?: number) { public async feed(page: Page = {}) {
return await firstValueFrom(this.http.get<Image[]>(`${this.url}/feed`, { return await firstValueFrom(this.http.get<Image[]>(`${this.url}/feed`,
...this.users.httpOptions, this.users.httpOptions({ params: page })
// params: { n, i } ));
})); }
public async userFeed(username: string, page: Page = {}) {
return await firstValueFrom(this.http.get<Image[]>(`${this.url}/feed/${username}`,
this.users.httpOptions({ params: page })
));
} }
public async upload(proto: CreateImageBody, file: File) { public async upload(proto: CreateImageBody, file: File) {

View File

@ -0,0 +1,16 @@
import { EventEmitter, Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class ScrollService {
public endReached = new EventEmitter();
private prevHeight = -1;
public signal(height: number) {
if (this.prevHeight !== height) this.endReached.emit();
this.prevHeight = height;
}
constructor() { }
}

View File

@ -5,7 +5,6 @@ import { firstValueFrom } from 'rxjs';
export interface User { export interface User {
username: string; username: string;
images: string[];
} }
@Injectable({ @Injectable({