mirror of
https://github.com/notherealmarco/WASAPhoto.git
synced 2025-03-13 05:29:09 +01:00
commit
3112eb364b
54 changed files with 10947 additions and 10125 deletions
8
.vscode/launch.json
vendored
8
.vscode/launch.json
vendored
|
@ -10,11 +10,11 @@
|
|||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"buildFlags": "",
|
||||
"buildFlags": "-tags webui",
|
||||
"program": "./cmd/webapi",
|
||||
"args": [
|
||||
"--db-filename", "/home/marco/wasa/wasadata/wasaphoto.db", "--data-path", "/home/marco/wasa/wasadata/data"
|
||||
]
|
||||
//"args": [
|
||||
// "--db-filename", "/home/marco/wasa/wasadata/wasaphoto.db", "--data-path", "/home/marco/wasa/wasadata/data"
|
||||
//]
|
||||
}
|
||||
]
|
||||
}
|
22
Dockerfile.backend
Normal file
22
Dockerfile.backend
Normal file
|
@ -0,0 +1,22 @@
|
|||
FROM golang:1.19.1 AS builder
|
||||
|
||||
### Copy Go code
|
||||
WORKDIR /src/
|
||||
COPY . .
|
||||
|
||||
### Build executables
|
||||
RUN go build -o /app/webapi ./cmd/webapi
|
||||
|
||||
|
||||
### Create final container
|
||||
FROM debian:bullseye
|
||||
|
||||
### Inform Docker about which port is used
|
||||
EXPOSE 3000 4000
|
||||
|
||||
### Copy the build executable from the builder image
|
||||
WORKDIR /app/
|
||||
COPY --from=builder /app/webapi ./
|
||||
|
||||
### Executable command
|
||||
CMD ["/app/webapi", "--db-filename", "/data/wasaphoto.db", "--data-path", "/data/data"]
|
34
Dockerfile.embedded
Normal file
34
Dockerfile.embedded
Normal file
|
@ -0,0 +1,34 @@
|
|||
FROM node:lts as ui-builder
|
||||
|
||||
### Copy Vue.js code
|
||||
WORKDIR /app
|
||||
COPY webui webui
|
||||
|
||||
### Build Vue.js into plain HTML/CSS/JS
|
||||
WORKDIR /app/webui
|
||||
RUN npm i
|
||||
RUN npm run build-embed
|
||||
|
||||
FROM golang:1.19.1 AS builder
|
||||
|
||||
### Copy Go code
|
||||
WORKDIR /src/
|
||||
COPY . .
|
||||
COPY --from=ui-builder /app/webui webui
|
||||
|
||||
### Build executables
|
||||
RUN go build -tags webui -o /app/webapi ./cmd/webapi
|
||||
|
||||
|
||||
### Create final container
|
||||
FROM debian:bullseye
|
||||
|
||||
### Inform Docker about which port is used
|
||||
EXPOSE 3000 4000
|
||||
|
||||
### Copy the build executable from the builder image
|
||||
WORKDIR /app/
|
||||
COPY --from=builder /app/webapi ./
|
||||
|
||||
### Executable command
|
||||
CMD ["/app/webapi", "--db-filename", "/data/wasaphoto.db", "--data-path", "/data/data"]
|
16
Dockerfile.frontend
Normal file
16
Dockerfile.frontend
Normal file
|
@ -0,0 +1,16 @@
|
|||
FROM node:lts as builder
|
||||
|
||||
### Copy Vue.js code
|
||||
WORKDIR /app
|
||||
COPY webui webui
|
||||
|
||||
### Build Vue.js into plain HTML/CSS/JS
|
||||
WORKDIR /app/webui
|
||||
RUN npm run build-prod
|
||||
|
||||
|
||||
### Create final container
|
||||
FROM nginx:stable
|
||||
|
||||
### Copy the (built) app from the builder image
|
||||
COPY --from=builder /app/webui/dist /usr/share/nginx/html
|
58
README.md
58
README.md
|
@ -4,7 +4,9 @@
|
|||
|
||||
*Keep in touch with your friends by sharing photos of special moments, thanks to WASAPhoto!*
|
||||
|
||||
*You canupload your photos directly from your PC, and they will be visible to everyone following you.*
|
||||
*You can upload your photos directly from your PC, and they will be visible to everyone following you.*
|
||||
|
||||
(Live demo: [https://wasaphoto.marcorealacci.me](https://wasaphoto.marcorealacci.me))
|
||||
|
||||
---
|
||||
|
||||
|
@ -17,6 +19,56 @@ This is my project for the Web And Software Architecture (WASA) class
|
|||
* An API specification using the OpenAPI standard
|
||||
* A backend written in the Go language
|
||||
* A frontend in Vue.js
|
||||
* ~~A Dockerfile to build a Docker image to deploy the project in a container.~~
|
||||
* Dockerfiles to deploy the backend and the frontend in containers.
|
||||
* Dockerfile.backend builds the container for the backend
|
||||
* Dockerfile.frontend builds the container for the frontend
|
||||
* Dockerfile.embedded builds the backend container, but the backend's webserver also delivers the frontend
|
||||
|
||||
*(Strikethrough parts are work in progress or still need to be implemented)*
|
||||
### Before building
|
||||
|
||||
If you're building the project in production mode (see below), you need to specify the base URL for the backend in `vite.config.js`.
|
||||
|
||||
|
||||
## Build & deploy
|
||||
|
||||
The only (officially) supported method is via Docker containers.
|
||||
|
||||
There are two supported methods.
|
||||
|
||||
#### Embedded build
|
||||
|
||||
This method is only recommended for testing purposes or instances with very few users (for performance reasons).
|
||||
|
||||
The following commands will build a single container to serve both frontend and backend.
|
||||
|
||||
```
|
||||
docker build -t wasaphoto -f Dockerfile.embedded .
|
||||
docker run -p 3000:3000 -v <path to data directory>:/data --name wasaphoto wasaphoto
|
||||
```
|
||||
|
||||
Everything will be up and running on port 3000 (including the Web UI).
|
||||
|
||||
|
||||
#### Production build
|
||||
|
||||
This method build two containers, one for the backend and a container that running nginx to serve the frontend.
|
||||
|
||||
This is very recommended on production envinoments.
|
||||
|
||||
1. Build and run the backend
|
||||
|
||||
```
|
||||
docker build -t wasabackend -f Dockerfile.backend .
|
||||
docker run -p 3000:3000 -v <path to data directory>:/data --name wasaphoto-backend wasabackend
|
||||
```
|
||||
2. Edit the `vite.config.js` file and replace `<your API URL>` with the backend's base URL.
|
||||
3. Build and run the frontend
|
||||
|
||||
```
|
||||
docker build -t wasafrontend -f Dockerfile.frontend .
|
||||
docker run -p 8080:80 --name wasaphoto-frontend wasafrontend
|
||||
```
|
||||
|
||||
The Web UI will be up and running on port 8080!
|
||||
|
||||
<your API URL>
|
||||
|
|
|
@ -81,6 +81,12 @@ func run() error {
|
|||
|
||||
logger.Infof("application initializing")
|
||||
|
||||
// Create the directories if they don't exist
|
||||
if err := os.MkdirAll(cfg.Data.Path, 0755); err != nil {
|
||||
logger.WithError(err).Error("error creating data directory")
|
||||
return fmt.Errorf("creating data directory: %w", err)
|
||||
}
|
||||
|
||||
// Start Database
|
||||
logger.Println("initializing database support")
|
||||
dbconn, err := sql.Open("sqlite3", cfg.DB.Filename)
|
||||
|
@ -120,6 +126,7 @@ func run() error {
|
|||
logger.WithError(err).Error("error creating the API server instance")
|
||||
return fmt.Errorf("creating the API server instance: %w", err)
|
||||
}
|
||||
|
||||
router := apirouter.Handler()
|
||||
|
||||
router, err = registerWebUI(router)
|
||||
|
|
|
@ -4,10 +4,11 @@ package main
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/notherealmarco/WASAPhoto/webui"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/notherealmarco/WASAPhoto/webui"
|
||||
)
|
||||
|
||||
func registerWebUI(hdl http.Handler) (http.Handler, error) {
|
||||
|
@ -20,6 +21,10 @@ func registerWebUI(hdl http.Handler) (http.Handler, error) {
|
|||
if strings.HasPrefix(r.RequestURI, "/dashboard/") {
|
||||
http.StripPrefix("/dashboard/", http.FileServer(http.FS(distDirectory))).ServeHTTP(w, r)
|
||||
return
|
||||
} else if r.RequestURI == "/" {
|
||||
// Redirect to dashboard
|
||||
http.Redirect(w, r, "/dashboard/", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
hdl.ServeHTTP(w, r)
|
||||
}), nil
|
||||
|
|
11
doc/api.yaml
11
doc/api.yaml
|
@ -1,4 +1,4 @@
|
|||
openapi: 3.0.3
|
||||
openapi: 3.0.2
|
||||
info:
|
||||
title: WASAPhoto API
|
||||
description: |-
|
||||
|
@ -210,7 +210,7 @@ paths:
|
|||
$ref: "#/components/schemas/generic_response"
|
||||
example:
|
||||
status: "Resource not found"
|
||||
'400': # todo: not sure if this is the right error code
|
||||
'400':
|
||||
description: Trying to follow a user that does not exist.
|
||||
content:
|
||||
application/json:
|
||||
|
@ -920,6 +920,7 @@ components:
|
|||
maxLength: 36
|
||||
description: The user ID.
|
||||
format: uuid
|
||||
pattern: '^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$' # RFC 4122
|
||||
example: "1b4e28ba-2fa1-11d2-883f-0016d3cca427"
|
||||
name_object:
|
||||
type: object
|
||||
|
@ -1080,9 +1081,9 @@ components:
|
|||
comment:
|
||||
$ref: "#/components/schemas/comment"
|
||||
comment:
|
||||
minLength: 5
|
||||
minLength: 1
|
||||
maxLength: 255
|
||||
pattern: ".*" #everything except newlines ^[*]{5, 255}$
|
||||
pattern: "^(.){1,255}$" # everything except newlines
|
||||
type: string
|
||||
example: "What a lovely picture! 😊"
|
||||
description: The comment's text
|
||||
|
@ -1092,7 +1093,7 @@ components:
|
|||
format: binary
|
||||
minLength: 1
|
||||
maxLength: 10485760 # 10 MB
|
||||
pattern: "((.|\n)*)" # todo: review. Btw this means "any string"
|
||||
pattern: "((.|\n)*)" # this accepts everything
|
||||
|
||||
generic_response:
|
||||
type: object
|
||||
|
|
|
@ -7,9 +7,11 @@ import (
|
|||
"github.com/notherealmarco/WASAPhoto/service/database"
|
||||
)
|
||||
|
||||
// AnonymousAuth is the authentication provider for non logged-in users
|
||||
type AnonymousAuth struct {
|
||||
}
|
||||
|
||||
// Returns a newly created AnonymousAuth instance
|
||||
func BuildAnonymous() *AnonymousAuth {
|
||||
return &AnonymousAuth{}
|
||||
}
|
||||
|
@ -18,14 +20,17 @@ func (u *AnonymousAuth) GetType() string {
|
|||
return "Anonymous"
|
||||
}
|
||||
|
||||
// Returns UNAUTHORIZED, as anonymous users are logged in
|
||||
func (u *AnonymousAuth) Authorized(db database.AppDatabase) (reqcontext.AuthStatus, error) {
|
||||
return reqcontext.UNAUTHORIZED, nil
|
||||
}
|
||||
|
||||
// Returns UNAUTHORIZED, as anonymous users are not logged in
|
||||
func (u *AnonymousAuth) UserAuthorized(db database.AppDatabase, uid string) (reqcontext.AuthStatus, error) {
|
||||
return reqcontext.UNAUTHORIZED, nil
|
||||
}
|
||||
|
||||
// Returns an empty string, as anonymous users have no user ID
|
||||
func (u *AnonymousAuth) GetUserID() string {
|
||||
return ""
|
||||
}
|
||||
|
|
|
@ -8,6 +8,8 @@ import (
|
|||
"github.com/notherealmarco/WASAPhoto/service/database"
|
||||
)
|
||||
|
||||
// BearerAuth is the authentication provider that authorizes users by Bearer tokens
|
||||
// In this case, a token is the unique identifier for a user.
|
||||
type BearerAuth struct {
|
||||
token string
|
||||
}
|
||||
|
@ -16,6 +18,8 @@ func (b *BearerAuth) GetType() string {
|
|||
return "Bearer"
|
||||
}
|
||||
|
||||
// Given the content of the Authorization header, returns a BearerAuth instance for the user
|
||||
// Returns an error if the header is not valid
|
||||
func BuildBearer(header string) (*BearerAuth, error) {
|
||||
if header == "" {
|
||||
return nil, errors.New("missing authorization header")
|
||||
|
@ -29,10 +33,12 @@ func BuildBearer(header string) (*BearerAuth, error) {
|
|||
return &BearerAuth{token: header[7:]}, nil
|
||||
}
|
||||
|
||||
// Returns the user ID of the user that is currently logged in
|
||||
func (b *BearerAuth) GetUserID() string {
|
||||
return b.token
|
||||
}
|
||||
|
||||
// Checks if the token is valid
|
||||
func (b *BearerAuth) Authorized(db database.AppDatabase) (reqcontext.AuthStatus, error) {
|
||||
// this is the way we manage authorization, the bearer token is the user id
|
||||
state, err := db.UserExists(b.token)
|
||||
|
@ -47,6 +53,7 @@ func (b *BearerAuth) Authorized(db database.AppDatabase) (reqcontext.AuthStatus,
|
|||
return reqcontext.UNAUTHORIZED, nil
|
||||
}
|
||||
|
||||
// Checks if the given user and the currently logged in user are the same user
|
||||
func (b *BearerAuth) UserAuthorized(db database.AppDatabase, uid string) (reqcontext.AuthStatus, error) {
|
||||
|
||||
// If uid is not a valid user, return USER_NOT_FOUND
|
||||
|
@ -60,6 +67,7 @@ func (b *BearerAuth) UserAuthorized(db database.AppDatabase, uid string) (reqcon
|
|||
}
|
||||
|
||||
if b.token == uid {
|
||||
// If the user is the same as the one in the token, check if the user does actually exist in the database
|
||||
auth, err := b.Authorized(db)
|
||||
|
||||
if err != nil {
|
||||
|
@ -68,5 +76,6 @@ func (b *BearerAuth) UserAuthorized(db database.AppDatabase, uid string) (reqcon
|
|||
|
||||
return auth, nil
|
||||
}
|
||||
// If the user is not the same as the one in the token, return FORBIDDEN
|
||||
return reqcontext.FORBIDDEN, nil
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// BuildAuth returns an Authorization implementation for the currently logged in user
|
||||
func BuildAuth(header string) (reqcontext.Authorization, error) {
|
||||
auth, err := BuildBearer(header)
|
||||
if err != nil {
|
||||
|
@ -21,6 +22,8 @@ func BuildAuth(header string) (reqcontext.Authorization, error) {
|
|||
return auth, nil
|
||||
}
|
||||
|
||||
// Given a user authorization function, if the function returns some error, it sends the error to the client and return false
|
||||
// Otherwise it returns true without sending anything to the client
|
||||
func SendAuthorizationError(f func(db database.AppDatabase, uid string) (reqcontext.AuthStatus, error), uid string, db database.AppDatabase, w http.ResponseWriter, l logrus.FieldLogger, notFoundStatus int) bool {
|
||||
auth, err := f(db, uid)
|
||||
if err != nil {
|
||||
|
@ -28,21 +31,25 @@ func SendAuthorizationError(f func(db database.AppDatabase, uid string) (reqcont
|
|||
return false
|
||||
}
|
||||
if auth == reqcontext.UNAUTHORIZED {
|
||||
// The token is not valid
|
||||
helpers.SendStatus(http.StatusUnauthorized, w, "Unauthorized", l)
|
||||
return false
|
||||
}
|
||||
if auth == reqcontext.FORBIDDEN {
|
||||
// The user is not authorized for this action
|
||||
helpers.SendStatus(http.StatusForbidden, w, "Forbidden", l)
|
||||
return false
|
||||
}
|
||||
// requested user is not found -> 404 as the resource is not found
|
||||
if auth == reqcontext.USER_NOT_FOUND {
|
||||
// Attempting to perform an action on a non-existent user
|
||||
helpers.SendStatus(notFoundStatus, w, "User not found", l)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Given a function that validates a token, if the function returns some error, it sends the error to the client and return false
|
||||
// Otherwise it returns true without sending anything to the client
|
||||
func SendErrorIfNotLoggedIn(f func(db database.AppDatabase) (reqcontext.AuthStatus, error), db database.AppDatabase, w http.ResponseWriter, l logrus.FieldLogger) bool {
|
||||
|
||||
auth, err := f(db)
|
||||
|
@ -53,6 +60,7 @@ func SendErrorIfNotLoggedIn(f func(db database.AppDatabase) (reqcontext.AuthStat
|
|||
}
|
||||
|
||||
if auth == reqcontext.UNAUTHORIZED {
|
||||
// The token is not valid
|
||||
helpers.SendStatus(http.StatusUnauthorized, w, "Unauthorized", l)
|
||||
return false
|
||||
}
|
||||
|
|
|
@ -66,6 +66,7 @@ func (rt *_router) PutBan(w http.ResponseWriter, r *http.Request, ps httprouter.
|
|||
return
|
||||
}
|
||||
|
||||
// Execute the query
|
||||
status, err := rt.db.BanUser(uid, banned)
|
||||
|
||||
if err != nil {
|
||||
|
@ -83,6 +84,13 @@ func (rt *_router) PutBan(w http.ResponseWriter, r *http.Request, ps httprouter.
|
|||
return
|
||||
}
|
||||
|
||||
// Removes the banning user to the banned user's followers (if present)
|
||||
_, err = rt.db.UnfollowUser(banned, uid)
|
||||
|
||||
if err != nil {
|
||||
helpers.SendInternalError(err, "Database error: UnfollowUser", w, rt.baseLogger)
|
||||
}
|
||||
|
||||
helpers.SendStatus(http.StatusCreated, w, "Success", rt.baseLogger)
|
||||
}
|
||||
|
||||
|
@ -95,6 +103,7 @@ func (rt *_router) DeleteBan(w http.ResponseWriter, r *http.Request, ps httprout
|
|||
return
|
||||
}
|
||||
|
||||
// Execute the query
|
||||
status, err := rt.db.UnbanUser(uid, banned)
|
||||
|
||||
if err != nil {
|
||||
|
|
|
@ -3,7 +3,6 @@ package api
|
|||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
|
@ -56,6 +55,7 @@ func (rt *_router) GetComments(w http.ResponseWriter, r *http.Request, ps httpro
|
|||
}
|
||||
|
||||
// send the response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
err = json.NewEncoder(w).Encode(comments)
|
||||
|
||||
if err != nil {
|
||||
|
@ -89,15 +89,7 @@ func (rt *_router) PostComment(w http.ResponseWriter, r *http.Request, ps httpro
|
|||
}
|
||||
|
||||
// check if the comment is valid (should not contain newlines and at be between 5 and 255 characters)
|
||||
stat, err := regexp.Match(`^[*]{5, 255}$`, []byte(request_body.Comment))
|
||||
|
||||
if err != nil {
|
||||
helpers.SendInternalError(err, "Error matching regex", w, rt.baseLogger)
|
||||
return
|
||||
}
|
||||
|
||||
if !stat {
|
||||
helpers.SendBadRequest(w, "Invalid comment", rt.baseLogger)
|
||||
if !helpers.MatchCommentOrBadRequest(request_body.Comment, w, rt.baseLogger) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -79,6 +79,7 @@ func (rt *_router) PutFollow(w http.ResponseWriter, r *http.Request, ps httprout
|
|||
return
|
||||
}
|
||||
|
||||
// Execute the query
|
||||
status, err := rt.db.FollowUser(follower, uid)
|
||||
|
||||
if err != nil {
|
||||
|
@ -109,6 +110,7 @@ func (rt *_router) DeleteFollow(w http.ResponseWriter, r *http.Request, ps httpr
|
|||
return
|
||||
}
|
||||
|
||||
// Execute the query
|
||||
status, err := rt.db.UnfollowUser(follower, uid)
|
||||
|
||||
if err != nil {
|
||||
|
|
|
@ -10,6 +10,8 @@ import (
|
|||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Tries to decode a json, if it fails, it returns Bad Request to the client and the function returns false
|
||||
// Otherwise it returns true without sending anything to the client
|
||||
func DecodeJsonOrBadRequest(r io.Reader, w http.ResponseWriter, v interface{}, l logrus.FieldLogger) bool {
|
||||
|
||||
err := json.NewDecoder(r).Decode(v)
|
||||
|
@ -20,6 +22,8 @@ func DecodeJsonOrBadRequest(r io.Reader, w http.ResponseWriter, v interface{}, l
|
|||
return true
|
||||
}
|
||||
|
||||
// Verifies if a user exists, if it doesn't, it returns Not Found to the client and the function returns false
|
||||
// Otherwise it returns true without sending anything to the client
|
||||
func VerifyUserOrNotFound(db database.AppDatabase, uid string, w http.ResponseWriter, l logrus.FieldLogger) bool {
|
||||
|
||||
user_exists, err := db.UserExists(uid)
|
||||
|
@ -36,6 +40,8 @@ func VerifyUserOrNotFound(db database.AppDatabase, uid string, w http.ResponseWr
|
|||
return true
|
||||
}
|
||||
|
||||
// Sends a generic status response
|
||||
// The response is a json object with a "status" field desribing the status of a request
|
||||
func SendStatus(httpStatus int, w http.ResponseWriter, description string, l logrus.FieldLogger) {
|
||||
w.WriteHeader(httpStatus)
|
||||
err := json.NewEncoder(w).Encode(structures.GenericResponse{Status: description})
|
||||
|
@ -44,40 +50,29 @@ func SendStatus(httpStatus int, w http.ResponseWriter, description string, l log
|
|||
}
|
||||
}
|
||||
|
||||
// Sends a Not Found error to the client
|
||||
func SendNotFound(w http.ResponseWriter, description string, l logrus.FieldLogger) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
err := json.NewEncoder(w).Encode(structures.GenericResponse{Status: description})
|
||||
if err != nil {
|
||||
l.WithError(err).Error("Error encoding json")
|
||||
}
|
||||
SendStatus(http.StatusNotFound, w, description, l)
|
||||
}
|
||||
|
||||
// Sends a Bad Request error to the client
|
||||
func SendBadRequest(w http.ResponseWriter, description string, l logrus.FieldLogger) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
err := json.NewEncoder(w).Encode(structures.GenericResponse{Status: description})
|
||||
if err != nil {
|
||||
l.WithError(err).Error("Error encoding json")
|
||||
}
|
||||
SendStatus(http.StatusBadRequest, w, description, l)
|
||||
}
|
||||
|
||||
// Sends a Bad Request error to the client and logs the given error
|
||||
func SendBadRequestError(err error, description string, w http.ResponseWriter, l logrus.FieldLogger) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
l.WithError(err).Error(description)
|
||||
err = json.NewEncoder(w).Encode(structures.GenericResponse{Status: description})
|
||||
if err != nil {
|
||||
l.WithError(err).Error("Error encoding json")
|
||||
}
|
||||
SendBadRequest(w, description, l)
|
||||
}
|
||||
|
||||
// Sends an Internal Server Error to the client and logs the given error
|
||||
func SendInternalError(err error, description string, w http.ResponseWriter, l logrus.FieldLogger) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
l.WithError(err).Error(description)
|
||||
err = json.NewEncoder(w).Encode(structures.GenericResponse{Status: description})
|
||||
if err != nil {
|
||||
l.WithError(err).Error("Error encoding json")
|
||||
}
|
||||
SendStatus(http.StatusInternalServerError, w, description, l)
|
||||
}
|
||||
|
||||
// Tries to roll back a transaction, if it fails it logs the error
|
||||
func RollbackOrLogError(tx database.DBTransaction, l logrus.FieldLogger) {
|
||||
err := tx.Rollback()
|
||||
if err != nil {
|
||||
|
@ -85,6 +80,8 @@ func RollbackOrLogError(tx database.DBTransaction, l logrus.FieldLogger) {
|
|||
}
|
||||
}
|
||||
|
||||
// Checks if a user is banned by another user, then it returns Not Found to the client and the function returns false
|
||||
// Otherwise it returns true whithout sending anything to the client
|
||||
func SendNotFoundIfBanned(db database.AppDatabase, uid string, banner string, w http.ResponseWriter, l logrus.FieldLogger) bool {
|
||||
banned, err := db.IsBanned(uid, banner)
|
||||
if err != nil {
|
||||
|
|
|
@ -6,10 +6,12 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
DEFAULT_LIMIT = 15 // don't know if should be moved to config
|
||||
DEFAULT_LIMIT = 30
|
||||
DEFAULT_OFFSET = 0
|
||||
)
|
||||
|
||||
// Get the start index and limit from the query.
|
||||
// If they are not present, use the default values.
|
||||
func GetLimits(query url.Values) (int, int, error) {
|
||||
|
||||
limit := DEFAULT_LIMIT
|
||||
|
|
44
service/api/helpers/regex-helpers.go
Normal file
44
service/api/helpers/regex-helpers.go
Normal file
|
@ -0,0 +1,44 @@
|
|||
package helpers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Given a string, a regex and an error description, if the string doesn't match the regex, it sends a bad request error to the client and return false
|
||||
// Otherwise it returns true without sending anything to the client
|
||||
func MatchRegexOrBadRequest(str string, regex string, error_description string, w http.ResponseWriter, l logrus.FieldLogger) bool {
|
||||
|
||||
stat, err := regexp.Match(regex, []byte(str))
|
||||
|
||||
if err != nil {
|
||||
SendInternalError(err, "Error while matching username regex", w, l)
|
||||
return false
|
||||
}
|
||||
|
||||
if !stat {
|
||||
// string didn't match the regex, so it's invalid, let's send a bad request error
|
||||
SendBadRequest(w, error_description, l)
|
||||
return false
|
||||
}
|
||||
// string matched the regex, so it's valid
|
||||
return true
|
||||
}
|
||||
|
||||
// Validates a username (must be between 3 and 16 characters long and can only contain letters, numbers and underscores)
|
||||
func MatchUsernameOrBadRequest(username string, w http.ResponseWriter, l logrus.FieldLogger) bool {
|
||||
return MatchRegexOrBadRequest(username,
|
||||
`^[a-zA-Z0-9_]{3,16}$`, "Username must be between 3 and 16 characters long and can only contain letters, numbers and underscores",
|
||||
w,
|
||||
l)
|
||||
}
|
||||
|
||||
// Validates a comment (must be between 1 and 255 characters long)
|
||||
func MatchCommentOrBadRequest(comment string, w http.ResponseWriter, l logrus.FieldLogger) bool {
|
||||
return MatchRegexOrBadRequest(comment,
|
||||
`^(.){1,255}$`, "Comment must be between 1 and 255 characters long",
|
||||
w,
|
||||
l)
|
||||
}
|
|
@ -1,16 +1,18 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"net/http"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/notherealmarco/WASAPhoto/service/api/helpers"
|
||||
)
|
||||
|
||||
// liveness is an HTTP handler that checks the API server status. If the server cannot serve requests (e.g., some
|
||||
// resources are not ready), this should reply with HTTP Status 500. Otherwise, with HTTP Status 200
|
||||
func (rt *_router) liveness(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
|
||||
/* Example of liveness check:
|
||||
if err := rt.DB.Ping(); err != nil {
|
||||
if err := rt.db.Ping(); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}*/
|
||||
}
|
||||
helpers.SendStatus(200, w, "Server is live!", rt.baseLogger)
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/notherealmarco/WASAPhoto/service/api/authorization"
|
||||
|
@ -32,36 +33,50 @@ func (rt *_router) PostPhoto(w http.ResponseWriter, r *http.Request, ps httprout
|
|||
}
|
||||
|
||||
path := rt.dataPath + "/photos/" + uid + "/" + strconv.FormatInt(photo_id, 10) + ".jpg"
|
||||
// todo: we should check if the body is a valid jpg image
|
||||
|
||||
if err = os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { // perms = 511
|
||||
helpers.SendInternalError(err, "Error creating directory", w, rt.baseLogger)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := os.Create(path)
|
||||
/*file, err := os.Create(path)
|
||||
if err != nil {
|
||||
helpers.SendInternalError(err, "Error creating file", w, rt.baseLogger)
|
||||
helpers.RollbackOrLogError(transaction, rt.baseLogger)
|
||||
return
|
||||
}*/
|
||||
|
||||
bytes, err := io.ReadAll(r.Body)
|
||||
|
||||
if err != nil {
|
||||
helpers.SendInternalError(err, "Error checking the file", w, rt.baseLogger)
|
||||
helpers.RollbackOrLogError(transaction, rt.baseLogger)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = io.Copy(file, r.Body); err != nil {
|
||||
mimeType := http.DetectContentType(bytes)
|
||||
|
||||
if !strings.HasPrefix(mimeType, "image/") {
|
||||
helpers.SendStatus(http.StatusBadRequest, w, mimeType+" file is not a valid image", rt.baseLogger)
|
||||
helpers.RollbackOrLogError(transaction, rt.baseLogger)
|
||||
return
|
||||
}
|
||||
|
||||
if err = os.WriteFile(path, bytes, 0644); err != nil {
|
||||
helpers.SendInternalError(err, "Error writing the file", w, rt.baseLogger)
|
||||
helpers.RollbackOrLogError(transaction, rt.baseLogger)
|
||||
return
|
||||
}
|
||||
|
||||
if err = file.Close(); err != nil {
|
||||
/*if err = file.Close(); err != nil {
|
||||
helpers.SendInternalError(err, "Error closing file", w, rt.baseLogger)
|
||||
helpers.RollbackOrLogError(transaction, rt.baseLogger)
|
||||
}
|
||||
}*/
|
||||
|
||||
err = transaction.Commit()
|
||||
|
||||
if err != nil {
|
||||
helpers.SendInternalError(err, "Error committing transaction", w, rt.baseLogger)
|
||||
//todo: should I roll back?
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -139,7 +154,7 @@ func (rt *_router) DeletePhoto(w http.ResponseWriter, r *http.Request, ps httpro
|
|||
if err != nil {
|
||||
helpers.SendInternalError(err, "Error deleting photo from database", w, rt.baseLogger)
|
||||
return
|
||||
} // todo: maybe let's use a transaction also here
|
||||
}
|
||||
|
||||
if !deleted {
|
||||
helpers.SendNotFound(w, "Photo not found", rt.baseLogger)
|
||||
|
|
|
@ -25,19 +25,36 @@ func (rt *_router) PostSession(w http.ResponseWriter, r *http.Request, ps httpro
|
|||
var request _reqbody
|
||||
err := json.NewDecoder(r.Body).Decode(&request)
|
||||
|
||||
var uid string
|
||||
if err == nil { // test if user exists
|
||||
uid, err = rt.db.GetUserID(request.Name)
|
||||
}
|
||||
if db_errors.EmptySet(err) { // user does not exist
|
||||
uid, err = rt.db.CreateUser(request.Name)
|
||||
}
|
||||
if err != nil { // handle any other error
|
||||
if err != nil {
|
||||
helpers.SendBadRequestError(err, "Bad request body", w, rt.baseLogger)
|
||||
return
|
||||
}
|
||||
|
||||
// test if user exists
|
||||
var uid string
|
||||
uid, err = rt.db.GetUserID(request.Name)
|
||||
|
||||
// check if the database returned an empty set error, if so, create the new user
|
||||
if db_errors.EmptySet(err) {
|
||||
|
||||
// before creating the user, check if the name is valid, otherwise send a bad request error
|
||||
if !helpers.MatchUsernameOrBadRequest(request.Name, w, rt.baseLogger) {
|
||||
return
|
||||
}
|
||||
|
||||
uid, err = rt.db.CreateUser(request.Name)
|
||||
}
|
||||
|
||||
// handle database errors
|
||||
if err != nil {
|
||||
helpers.SendInternalError(err, "Database error", w, rt.baseLogger)
|
||||
return
|
||||
}
|
||||
|
||||
// set the response header
|
||||
w.Header().Set("content-type", "application/json")
|
||||
|
||||
// encode the response body
|
||||
err = json.NewEncoder(w).Encode(_respbody{UID: uid})
|
||||
|
||||
if err != nil {
|
||||
|
|
|
@ -2,7 +2,6 @@ package api
|
|||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/notherealmarco/WASAPhoto/service/api/authorization"
|
||||
|
@ -15,33 +14,32 @@ import (
|
|||
func (rt *_router) UpdateUsername(w http.ResponseWriter, r *http.Request, ps httprouter.Params, ctx reqcontext.RequestContext) {
|
||||
|
||||
uid := ps.ByName("user_id")
|
||||
|
||||
// check if the user is changing his own username
|
||||
if !authorization.SendAuthorizationError(ctx.Auth.UserAuthorized, uid, rt.db, w, rt.baseLogger, http.StatusNotFound) {
|
||||
return
|
||||
}
|
||||
|
||||
// decode request body
|
||||
var req structures.UserDetails
|
||||
if !helpers.DecodeJsonOrBadRequest(r.Body, w, &req, rt.baseLogger) {
|
||||
return
|
||||
}
|
||||
|
||||
stat, err := regexp.Match(`^[a-zA-Z0-9_]{3,16}$`, []byte(req.Name))
|
||||
|
||||
if err != nil {
|
||||
helpers.SendInternalError(err, "Error while matching username", w, rt.baseLogger)
|
||||
return
|
||||
}
|
||||
|
||||
if !stat { //todo: sta regex non me piace
|
||||
helpers.SendBadRequest(w, "Username must be between 3 and 16 characters long and can only contain letters, numbers and underscores", rt.baseLogger)
|
||||
// check if the username is valid, and if it's not, send a bad request error
|
||||
if !helpers.MatchUsernameOrBadRequest(req.Name, w, rt.baseLogger) {
|
||||
return
|
||||
}
|
||||
|
||||
status, err := rt.db.UpdateUsername(uid, req.Name)
|
||||
|
||||
// check if the username already exists
|
||||
if status == database.ERR_EXISTS {
|
||||
helpers.SendStatus(http.StatusConflict, w, "Username already exists", rt.baseLogger)
|
||||
return
|
||||
}
|
||||
|
||||
// handle any other database error
|
||||
if err != nil {
|
||||
helpers.SendInternalError(err, "Database error: UpdateUsername", w, rt.baseLogger)
|
||||
return
|
||||
|
|
|
@ -11,9 +11,17 @@ const (
|
|||
USER_NOT_FOUND = 3
|
||||
)
|
||||
|
||||
// Authorization is the interface for an authorization provider
|
||||
type Authorization interface {
|
||||
// Returns the type of the authorization provider
|
||||
GetType() string
|
||||
|
||||
// Returns the ID of the currently logged in user
|
||||
GetUserID() string
|
||||
|
||||
// Checks if the token is valid
|
||||
Authorized(db database.AppDatabase) (AuthStatus, error)
|
||||
|
||||
// Checks if the given user and the currently logged in user are the same user
|
||||
UserAuthorized(db database.AppDatabase, uid string) (AuthStatus, error)
|
||||
}
|
||||
|
|
|
@ -79,8 +79,11 @@ type AppDatabase interface {
|
|||
Ping() error
|
||||
}
|
||||
|
||||
// DBTransaction is the interface for a generic database transaction
|
||||
type DBTransaction interface {
|
||||
// Commit commits the transaction
|
||||
Commit() error
|
||||
// Rollback rolls back the transaction
|
||||
Rollback() error
|
||||
}
|
||||
|
||||
|
@ -95,16 +98,17 @@ func New(db *sql.DB) (AppDatabase, error) {
|
|||
return nil, errors.New("database is required when building a AppDatabase")
|
||||
}
|
||||
|
||||
// Check if tables exist. If not, the database is empty, and we need to create the structure
|
||||
// Check if some table exists. If not, the database is empty, and we need to create the structure
|
||||
var tableName string
|
||||
//todo: check for all the tables, not just users
|
||||
|
||||
err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='users';`).Scan(&tableName)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// Database is empty, let's create the structure
|
||||
sqlStmt := `CREATE TABLE "users" (
|
||||
"uid" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL UNIQUE,
|
||||
PRIMARY KEY("uid")
|
||||
)` // todo: one query is enough! We are we doing a query per table?
|
||||
)`
|
||||
_, err = db.Exec(sqlStmt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating database structure: %w", err)
|
||||
|
|
|
@ -103,6 +103,7 @@ func (db *appdbimpl) GetComments(uid string, photo_id int64, requesting_uid stri
|
|||
AND "bans"."ban" = ?
|
||||
)
|
||||
AND "u"."uid" = "c"."user"
|
||||
ORDER BY "c"."date" DESC
|
||||
LIMIT ?
|
||||
OFFSET ?`, photo_id, requesting_uid, limit, start_index)
|
||||
|
||||
|
|
|
@ -96,6 +96,7 @@ func (db *appdbimpl) UnlikePhoto(uid string, photo int64, liker_uid string) (Que
|
|||
// But our DB implementation only requires the photo id.
|
||||
exists, err := db.photoExists(uid, photo)
|
||||
if err != nil || !exists {
|
||||
// The photo does not exist, or the user has been banned
|
||||
return ERR_NOT_FOUND, err
|
||||
}
|
||||
|
||||
|
@ -111,6 +112,7 @@ func (db *appdbimpl) UnlikePhoto(uid string, photo int64, liker_uid string) (Que
|
|||
return ERR_INTERNAL, err
|
||||
}
|
||||
|
||||
// The user was not liking the photo
|
||||
if rows == 0 {
|
||||
return ERR_NOT_FOUND, nil
|
||||
}
|
||||
|
|
|
@ -18,7 +18,6 @@ func (db *appdbimpl) PostPhoto(uid string) (DBTransaction, int64, error) {
|
|||
err_rb := tx.Rollback()
|
||||
// If rollback fails, we return the original error plus the rollback error
|
||||
if err_rb != nil {
|
||||
// todo: we are losing track of err_rb here
|
||||
err = fmt.Errorf("Rollback error. Rollback cause: %w", err)
|
||||
}
|
||||
|
||||
|
@ -30,7 +29,6 @@ func (db *appdbimpl) PostPhoto(uid string) (DBTransaction, int64, error) {
|
|||
err_rb := tx.Rollback()
|
||||
// If rollback fails, we return the original error plus the rollback error
|
||||
if err_rb != nil {
|
||||
// todo: we are losing track of err_rb here
|
||||
err = fmt.Errorf("Rollback error. Rollback cause: %w", err)
|
||||
}
|
||||
|
||||
|
@ -66,6 +64,7 @@ func (db *appdbimpl) photoExists(uid string, photo int64) (bool, error) {
|
|||
return cnt > 0, nil
|
||||
}
|
||||
|
||||
// Check if a given photo owned by a given user exists, and the requesting user is not banned by the author
|
||||
func (db *appdbimpl) PhotoExists(uid string, photo int64, requesting_uid string) (bool, error) {
|
||||
|
||||
var cnt int64
|
||||
|
|
|
@ -5,8 +5,6 @@ import (
|
|||
"github.com/notherealmarco/WASAPhoto/service/structures"
|
||||
)
|
||||
|
||||
//this should be changed, but we need to change OpenAPI first
|
||||
|
||||
// Get user profile, including username, followers, following, and photos
|
||||
func (db *appdbimpl) GetUserProfile(uid string, requesting_uid string) (QueryResult, *structures.UserProfile, error) {
|
||||
// Get user info
|
||||
|
|
|
@ -2,14 +2,17 @@ package database
|
|||
|
||||
import "database/sql"
|
||||
|
||||
// dbtransaction is a struct to represent an SQL transaction, it implements the DBTransaction interface
|
||||
type dbtransaction struct {
|
||||
c *sql.Tx
|
||||
}
|
||||
|
||||
func (tx *dbtransaction) Commit() error {
|
||||
// Commit the SQL transaction
|
||||
return tx.c.Commit()
|
||||
}
|
||||
|
||||
func (tx *dbtransaction) Rollback() error {
|
||||
// Rollback the SQL transaction
|
||||
return tx.c.Rollback()
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ package database
|
|||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/notherealmarco/WASAPhoto/service/database/db_errors"
|
||||
|
@ -41,28 +42,57 @@ func (db *appdbimpl) UserExistsNotBanned(uid string, requesting_uid string) (boo
|
|||
// Get user id by username
|
||||
func (db *appdbimpl) GetUserID(name string) (string, error) {
|
||||
var uid string
|
||||
err := db.c.QueryRow(`SELECT "uid" FROM "users" WHERE "name" = ?`, name).Scan(&uid)
|
||||
err := db.c.QueryRow(`SELECT "uid" FROM "users" WHERE "name" LIKE ?`, name).Scan(&uid)
|
||||
return uid, err
|
||||
}
|
||||
|
||||
// Create a new user
|
||||
func (db *appdbimpl) CreateUser(name string) (string, error) {
|
||||
|
||||
// check if username is taken (case insensitive)
|
||||
exists, err := db.nameExists(name)
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
} else if exists {
|
||||
return "", errors.New("username already exists")
|
||||
}
|
||||
|
||||
// create new user id
|
||||
uid, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// insert the new user into the database
|
||||
_, err = db.c.Exec(`INSERT INTO "users" ("uid", "name") VALUES (?, ?)`, uid.String(), name)
|
||||
return uid.String(), err
|
||||
}
|
||||
|
||||
// Check if username exists
|
||||
func (db *appdbimpl) nameExists(name string) (bool, error) {
|
||||
var cnt int
|
||||
err := db.c.QueryRow(`SELECT COUNT(*) FROM "users" WHERE "name" LIKE ?`, name).Scan(&cnt)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return cnt > 0, nil
|
||||
}
|
||||
|
||||
// Update username
|
||||
func (db *appdbimpl) UpdateUsername(uid string, name string) (QueryResult, error) {
|
||||
_, err := db.c.Exec(`UPDATE "users" SET "name" = ? WHERE "uid" = ?`, name, uid)
|
||||
|
||||
if db_errors.UniqueViolation(err) {
|
||||
// check if username is taken (case insensitive)
|
||||
exists, err := db.nameExists(name)
|
||||
|
||||
if err != nil {
|
||||
return ERR_INTERNAL, err
|
||||
} else if exists {
|
||||
return ERR_EXISTS, nil
|
||||
}
|
||||
|
||||
_, err = db.c.Exec(`UPDATE "users" SET "name" = ? WHERE "uid" = ?`, name, uid)
|
||||
|
||||
if err != nil {
|
||||
return ERR_INTERNAL, err
|
||||
}
|
||||
|
@ -95,7 +125,7 @@ func (db *appdbimpl) GetUserFollowers(uid string, requesting_uid string, start_i
|
|||
|
||||
AND "followed" = ?
|
||||
LIMIT ?
|
||||
OFFSET ?`, uid, requesting_uid, limit, start_index)
|
||||
OFFSET ?`, requesting_uid, uid, limit, start_index)
|
||||
|
||||
followers, err := db.uidNameQuery(rows, err)
|
||||
|
||||
|
@ -107,7 +137,7 @@ func (db *appdbimpl) GetUserFollowers(uid string, requesting_uid string, start_i
|
|||
}
|
||||
|
||||
// Get user following
|
||||
func (db *appdbimpl) GetUserFollowing(uid string, requesting_uid string, start_index int, offset int) (QueryResult, *[]structures.UIDName, error) {
|
||||
func (db *appdbimpl) GetUserFollowing(uid string, requesting_uid string, start_index int, limit int) (QueryResult, *[]structures.UIDName, error) {
|
||||
|
||||
// user may exist but have no followers
|
||||
exists, err := db.UserExistsNotBanned(uid, requesting_uid)
|
||||
|
@ -120,7 +150,7 @@ func (db *appdbimpl) GetUserFollowing(uid string, requesting_uid string, start_i
|
|||
return ERR_NOT_FOUND, nil, nil
|
||||
}
|
||||
|
||||
rows, err := db.c.Query(`SELECT "followed", "user"."name" FROM "follows", "users"
|
||||
rows, err := db.c.Query(`SELECT "followed", "users"."name" FROM "follows", "users"
|
||||
WHERE "follows"."followed" = "users"."uid"
|
||||
|
||||
AND "follows"."followed" NOT IN (
|
||||
|
@ -131,7 +161,7 @@ func (db *appdbimpl) GetUserFollowing(uid string, requesting_uid string, start_i
|
|||
|
||||
AND "follower" = ?
|
||||
LIMIT ?
|
||||
OFFSET ?`, uid, requesting_uid, offset, start_index)
|
||||
OFFSET ?`, requesting_uid, uid, limit, start_index)
|
||||
|
||||
following, err := db.uidNameQuery(rows, err)
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ package db_errors
|
|||
|
||||
import "strings"
|
||||
|
||||
// Returns true if the query result has no rows
|
||||
// Returns true if the error is a "no rows in result set" error
|
||||
func EmptySet(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
|
@ -10,6 +10,7 @@ func EmptySet(err error) bool {
|
|||
return strings.Contains(err.Error(), "no rows in result set")
|
||||
}
|
||||
|
||||
// Returns true if the error is a Unique constraint violation error
|
||||
func UniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
|
@ -17,6 +18,7 @@ func UniqueViolation(err error) bool {
|
|||
return strings.Contains(err.Error(), "UNIQUE constraint failed")
|
||||
}
|
||||
|
||||
// Returns true if the error is a Foreign Key constraint violation error
|
||||
func ForeignKeyViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
package database
|
||||
|
||||
// SetName is an example that shows you how to execute insert/update
|
||||
func (db *appdbimpl) SetName(name string) error {
|
||||
_, err := db.c.Exec("INSERT INTO example_table (id, name) VALUES (1, ?)", name)
|
||||
return err
|
||||
}
|
BIN
wasaphoto.db
BIN
wasaphoto.db
Binary file not shown.
BIN
webapi
BIN
webapi
Binary file not shown.
|
@ -1,7 +1,7 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Example app</title>
|
||||
<title>WASAPhoto</title>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
|
154
webui/node_modules/vite/dist/node/chunks/dep-94c1417a.js
generated
vendored
154
webui/node_modules/vite/dist/node/chunks/dep-94c1417a.js
generated
vendored
|
@ -159,83 +159,83 @@ var pify$1 = pify$2.exports = function (obj, P, opts) {
|
|||
|
||||
pify$1.all = pify$1;
|
||||
|
||||
var fs = require$$0__default;
|
||||
var path$2 = require$$0;
|
||||
var pify = pify$2.exports;
|
||||
|
||||
var stat = pify(fs.stat);
|
||||
var readFile = pify(fs.readFile);
|
||||
var resolve = path$2.resolve;
|
||||
|
||||
var cache = Object.create(null);
|
||||
|
||||
function convert(content, encoding) {
|
||||
if (Buffer.isEncoding(encoding)) {
|
||||
return content.toString(encoding);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
readCache$1.exports = function (path, encoding) {
|
||||
path = resolve(path);
|
||||
|
||||
return stat(path).then(function (stats) {
|
||||
var item = cache[path];
|
||||
|
||||
if (item && item.mtime.getTime() === stats.mtime.getTime()) {
|
||||
return convert(item.content, encoding);
|
||||
}
|
||||
|
||||
return readFile(path).then(function (data) {
|
||||
cache[path] = {
|
||||
mtime: stats.mtime,
|
||||
content: data
|
||||
};
|
||||
|
||||
return convert(data, encoding);
|
||||
});
|
||||
}).catch(function (err) {
|
||||
cache[path] = null;
|
||||
return Promise.reject(err);
|
||||
});
|
||||
};
|
||||
|
||||
readCache$1.exports.sync = function (path, encoding) {
|
||||
path = resolve(path);
|
||||
|
||||
try {
|
||||
var stats = fs.statSync(path);
|
||||
var item = cache[path];
|
||||
|
||||
if (item && item.mtime.getTime() === stats.mtime.getTime()) {
|
||||
return convert(item.content, encoding);
|
||||
}
|
||||
|
||||
var data = fs.readFileSync(path);
|
||||
|
||||
cache[path] = {
|
||||
mtime: stats.mtime,
|
||||
content: data
|
||||
};
|
||||
|
||||
return convert(data, encoding);
|
||||
} catch (err) {
|
||||
cache[path] = null;
|
||||
throw err;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
readCache$1.exports.get = function (path, encoding) {
|
||||
path = resolve(path);
|
||||
if (cache[path]) {
|
||||
return convert(cache[path].content, encoding);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
readCache$1.exports.clear = function () {
|
||||
cache = Object.create(null);
|
||||
var fs = require$$0__default;
|
||||
var path$2 = require$$0;
|
||||
var pify = pify$2.exports;
|
||||
|
||||
var stat = pify(fs.stat);
|
||||
var readFile = pify(fs.readFile);
|
||||
var resolve = path$2.resolve;
|
||||
|
||||
var cache = Object.create(null);
|
||||
|
||||
function convert(content, encoding) {
|
||||
if (Buffer.isEncoding(encoding)) {
|
||||
return content.toString(encoding);
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
readCache$1.exports = function (path, encoding) {
|
||||
path = resolve(path);
|
||||
|
||||
return stat(path).then(function (stats) {
|
||||
var item = cache[path];
|
||||
|
||||
if (item && item.mtime.getTime() === stats.mtime.getTime()) {
|
||||
return convert(item.content, encoding);
|
||||
}
|
||||
|
||||
return readFile(path).then(function (data) {
|
||||
cache[path] = {
|
||||
mtime: stats.mtime,
|
||||
content: data
|
||||
};
|
||||
|
||||
return convert(data, encoding);
|
||||
});
|
||||
}).catch(function (err) {
|
||||
cache[path] = null;
|
||||
return Promise.reject(err);
|
||||
});
|
||||
};
|
||||
|
||||
readCache$1.exports.sync = function (path, encoding) {
|
||||
path = resolve(path);
|
||||
|
||||
try {
|
||||
var stats = fs.statSync(path);
|
||||
var item = cache[path];
|
||||
|
||||
if (item && item.mtime.getTime() === stats.mtime.getTime()) {
|
||||
return convert(item.content, encoding);
|
||||
}
|
||||
|
||||
var data = fs.readFileSync(path);
|
||||
|
||||
cache[path] = {
|
||||
mtime: stats.mtime,
|
||||
content: data
|
||||
};
|
||||
|
||||
return convert(data, encoding);
|
||||
} catch (err) {
|
||||
cache[path] = null;
|
||||
throw err;
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
readCache$1.exports.get = function (path, encoding) {
|
||||
path = resolve(path);
|
||||
if (cache[path]) {
|
||||
return convert(cache[path].content, encoding);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
readCache$1.exports.clear = function () {
|
||||
cache = Object.create(null);
|
||||
};
|
||||
|
||||
const readCache = readCache$1.exports;
|
||||
|
|
12434
webui/node_modules/vite/dist/node/chunks/dep-9d3f225a.js
generated
vendored
12434
webui/node_modules/vite/dist/node/chunks/dep-9d3f225a.js
generated
vendored
File diff suppressed because it is too large
Load diff
6584
webui/node_modules/vite/dist/node/index.d.ts
generated
vendored
6584
webui/node_modules/vite/dist/node/index.d.ts
generated
vendored
File diff suppressed because it is too large
Load diff
|
@ -3,6 +3,7 @@
|
|||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev-extern-backend": "vite --mode developement-external",
|
||||
"build-dev": "vite build --mode development",
|
||||
"build-prod": "vite build --mode production",
|
||||
"build-embed": "vite build --mode production --base=/dashboard/",
|
||||
|
|
|
@ -1,83 +1,108 @@
|
|||
<script setup>
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
</script>
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
props: ["user_id", "name", "date", "comments", "likes", "photo_id", "liked"],
|
||||
data: function () {
|
||||
return {
|
||||
my_id: sessionStorage.getItem("token"),
|
||||
// Data for the modal
|
||||
modalTitle: "Modal Title",
|
||||
modalMsg: "Modal Message",
|
||||
|
||||
// Whether the user is logged in
|
||||
logged_in: true,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// Function to show a modal
|
||||
// can be called by any view or component
|
||||
// title: title of the modal
|
||||
// message: message to show in the modal
|
||||
showModal(title, message) {
|
||||
// Set the modal data
|
||||
this.modalTitle = title
|
||||
this.modalMsg = message
|
||||
|
||||
// Show the modal
|
||||
this.$refs.errModal.showModal()
|
||||
},
|
||||
|
||||
// Sets the login status to true
|
||||
// to show the navigation buttons
|
||||
setLoggedIn() {
|
||||
this.logged_in = true
|
||||
},
|
||||
|
||||
// Disconnects the current logged in user
|
||||
logout() {
|
||||
localStorage.removeItem("token")
|
||||
sessionStorage.removeItem("token")
|
||||
this.logged_in = false
|
||||
this.$router.push({ path: "/login" })
|
||||
}
|
||||
},
|
||||
|
||||
// Called when the root view is mounted
|
||||
mounted() {
|
||||
// Check if the user is already logged in
|
||||
this.$axiosUpdate()
|
||||
|
||||
// Configure axios interceptors
|
||||
this.$axios.interceptors.response.use(response => {
|
||||
// Leave response as is
|
||||
return response;
|
||||
}, error => {
|
||||
if (error.response.status != 0) {
|
||||
// If the response is 401, redirect to /login
|
||||
if (error.response.status === 401) {
|
||||
this.$router.push({ path: '/login' })
|
||||
this.logged_in = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the error message from the server in a modal
|
||||
this.showModal("Error " + error.response.status, error.response.data['status'])
|
||||
return;
|
||||
}
|
||||
// Show the error message from axios in a modal
|
||||
this.showModal("Error", error.toString());
|
||||
return;
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<!--<header class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0 shadow">
|
||||
<a class="navbar-brand col-md-3 col-lg-2 me-0 px-3 fs-6" href="#/">WASAPhoto</a>
|
||||
<button class="navbar-toggler position-absolute d-md-none collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
</header>-->
|
||||
<!-- Modal to show error messages -->
|
||||
<Modal ref="errModal" id="errorModal" :title="modalTitle">
|
||||
{{ modalMsg }}
|
||||
</Modal>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<!--<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse">
|
||||
<div class="position-sticky pt-3 sidebar-sticky">
|
||||
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted text-uppercase">
|
||||
<span>WASAPhoto</span>
|
||||
</h6>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<RouterLink to="/" class="nav-link">
|
||||
<svg class="feather"><use href="/feather-sprite-v4.29.0.svg#home"/></svg>
|
||||
Stream
|
||||
</RouterLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<RouterLink to="/link1" class="nav-link">
|
||||
<svg class="feather"><use href="/feather-sprite-v4.29.0.svg#layout"/></svg>
|
||||
Search
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h6 class="sidebar-heading d-flex justify-content-between align-items-center px-3 mt-4 mb-1 text-muted text-uppercase">
|
||||
<span>Account</span>
|
||||
</h6>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<RouterLink :to="'/some/' + 'variable_here' + '/path'" class="nav-link">
|
||||
<svg class="feather"><use href="/feather-sprite-v4.29.0.svg#file-text"/></svg>
|
||||
Your profile
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>-->
|
||||
|
||||
<!---<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">-->
|
||||
<main class="mb-5">
|
||||
<main>
|
||||
<!-- The view is rendered here -->
|
||||
<RouterView />
|
||||
<div v-if="logged_in" class="mb-5 pb-3"></div> <!-- Empty div to avoid hiding items under the navbar. todo: find a better way to do this -->
|
||||
</main>
|
||||
|
||||
<nav id="global-nav" class="navbar fixed-bottom navbar-light bg-light row">
|
||||
<!-- Bottom navigation buttons -->
|
||||
<nav v-if="logged_in" id="global-nav" class="navbar fixed-bottom navbar-light bg-light">
|
||||
<div class="collapse navbar-collapse" id="navbarNav"></div>
|
||||
<RouterLink to="/" class="col-4 text-center">
|
||||
<i class="bi bi-house text-dark" style="font-size: 2em"></i>
|
||||
</RouterLink>
|
||||
<RouterLink to="/search" class="col-4 text-center">
|
||||
<i class="bi bi-search text-dark" style="font-size: 2em"></i>
|
||||
</RouterLink>
|
||||
<RouterLink :to="'/profile/' + my_id" class="col-4 text-center">
|
||||
<i class="bi bi-person text-dark" style="font-size: 2em"></i>
|
||||
</RouterLink>
|
||||
<RouterLink to="/" class="col-4 text-center">
|
||||
<i class="bi bi-house text-dark" style="font-size: 2em"></i>
|
||||
</RouterLink>
|
||||
<RouterLink to="/search" class="col-4 text-center">
|
||||
<i class="bi bi-search text-dark" style="font-size: 2em"></i>
|
||||
</RouterLink>
|
||||
<RouterLink to="/profile/me" class="col-4 text-center">
|
||||
<i class="bi bi-person text-dark" style="font-size: 2em"></i>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Make the active navigation button a little bit bigger */
|
||||
#global-nav a.router-link-active {
|
||||
font-size: 1.2em
|
||||
}
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
<script>
|
||||
export default {
|
||||
// The error message to display
|
||||
props: ['msg']
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- This component renders an error message -->
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{{ msg }}
|
||||
</div>
|
||||
|
|
49
webui/src/components/IntersectionObserver.vue
Normal file
49
webui/src/components/IntersectionObserver.vue
Normal file
|
@ -0,0 +1,49 @@
|
|||
<script>
|
||||
// This component emits an event when the sentinal element is intersecting the viewport
|
||||
// (used to load more content when the user scrolls down)
|
||||
|
||||
// This component uses JavaScript's IntersectionObserver API
|
||||
|
||||
export default {
|
||||
name: 'IntersectionObserver',
|
||||
props: {
|
||||
sentinalName: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
// Whether the sentinal element is intersecting the viewport
|
||||
isIntersectingElement: false,
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
// Emit an event when the sentinal element is intersecting the viewport
|
||||
isIntersectingElement: function (value) {
|
||||
if (!value) return
|
||||
this.$emit('on-intersection-element')
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const sentinal = this.$refs[this.sentinalName]
|
||||
|
||||
// Create an observer to check if the sentinal element is intersecting the viewport
|
||||
const handler = (entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
this.isIntersectingElement = true
|
||||
}
|
||||
else {
|
||||
this.isIntersectingElement = false
|
||||
}
|
||||
}
|
||||
const observer = new window.IntersectionObserver(handler)
|
||||
observer.observe(sentinal)
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- The sentinal element -->
|
||||
<div :ref="sentinalName" class="w-full h-px relative" />
|
||||
</template>
|
|
@ -5,6 +5,7 @@ export default {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<!-- This component renders a loading spinner if the loading prop is true -->
|
||||
<div v-if="loading">
|
||||
<div style="text-align: center">
|
||||
<div class="spinner-grow" role="status">
|
||||
|
@ -14,5 +15,3 @@ export default {
|
|||
</div>
|
||||
<div v-if="!loading"><slot /></div>
|
||||
</template>
|
||||
|
||||
<style></style>
|
||||
|
|
43
webui/src/components/Modal.vue
Normal file
43
webui/src/components/Modal.vue
Normal file
|
@ -0,0 +1,43 @@
|
|||
<script>
|
||||
export default {
|
||||
props: ["id", "title"],
|
||||
methods: {
|
||||
// Visit the user's profile
|
||||
showModal() {
|
||||
this.$refs['open' + this.id].click();
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- This components renders a Bootstrap modal -->
|
||||
<!-- The modal contains a title and a message -->
|
||||
|
||||
<!-- Invisible button to open the modal -->
|
||||
<button :ref="'open' + id" type="button" class="btn btn-primary" style="display: none" data-bs-toggle="modal" :data-bs-target="'#' + id" />
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" :id="id" tabindex="-1" :aria-labelledby="id + 'Label'" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
|
||||
<!-- Modal title -->
|
||||
<h5 class="modal-title" :id="id + 'Label'">{{ title }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
<!-- Modal body -->
|
||||
<div class="modal-body">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Footer with close button -->
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -1,89 +1,199 @@
|
|||
<script>
|
||||
|
||||
export default {
|
||||
props: ["user_id", "name", "date", "comments", "likes", "photo_id", "liked", "my_id"],
|
||||
data: function() {
|
||||
props: ["user_id", "name", "date", "comments", "likes", "photo_id", "liked"],
|
||||
data: function () {
|
||||
return {
|
||||
imageSrc: "",
|
||||
errorMsg: null,
|
||||
// Whether the image is loaded (disables the spinner)
|
||||
imageReady: false,
|
||||
|
||||
// Likes and comments
|
||||
post_liked: this.liked,
|
||||
post_like_cnt: this.likes,
|
||||
post_comments_cnt: this.comments,
|
||||
comments_data: [],
|
||||
comments_start_idx: 0,
|
||||
comments_shown: false,
|
||||
commentMsg: "",
|
||||
|
||||
// Whether the comments have ended (no more comments to load)
|
||||
data_ended: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// Visit the user's profile
|
||||
visitUser() {
|
||||
this.$router.push({ path: "/profile/" + this.user_id });
|
||||
},
|
||||
|
||||
// Post a new comment
|
||||
postComment() {
|
||||
this.$axios.post("/users/" + this.user_id + "/photos/" + this.photo_id + "/comments", {
|
||||
"comment": this.commentMsg,
|
||||
"user_id": this.$currentSession(),
|
||||
}).then(response => {
|
||||
if (response == null) return // the interceptors returns null if something goes bad
|
||||
|
||||
// Reset the comment input and update the counter
|
||||
this.commentMsg = "";
|
||||
this.post_comments_cnt++;
|
||||
|
||||
// Fetch comments from the server
|
||||
this.comments_data = [];
|
||||
this.comments_start_idx = 0;
|
||||
this.getComments();
|
||||
})
|
||||
},
|
||||
|
||||
// Show or hide the comments section
|
||||
showHideComments() {
|
||||
// If comments are already shown, hide them and reset the data
|
||||
if (this.comments_shown) {
|
||||
this.comments_shown = false;
|
||||
this.comments_data = [];
|
||||
this.comments_start_idx = 0;
|
||||
return;
|
||||
}
|
||||
this.getComments();
|
||||
},
|
||||
|
||||
// Fetch comments from the server
|
||||
getComments() {
|
||||
this.data_ended = false
|
||||
|
||||
this.$axios.get("/users/" + this.user_id + "/photos/" + this.photo_id +
|
||||
"/comments?limit=5&start_index=" + this.comments_start_idx).then(response => {
|
||||
|
||||
// If there are no more comments, set the flag
|
||||
if (response.data.length == 0 || response.data.length < 5) this.data_ended = true;
|
||||
|
||||
// Otherwise increment the start index
|
||||
else this.comments_start_idx += 5;
|
||||
|
||||
// Append the comments to the array (they will be rendered)
|
||||
this.comments_data = this.comments_data.concat(response.data);
|
||||
this.comments_shown = true;
|
||||
})
|
||||
},
|
||||
|
||||
// Like the photo
|
||||
like() {
|
||||
this.$axios.put("/users/" + this.user_id + "/photos/" + this.photo_id + "/likes/" + this.my_id).then(response => {
|
||||
this.$axios.put("/users/" + this.user_id + "/photos/" + this.photo_id + "/likes/" + this.$currentSession()).then(response => {
|
||||
if (response == null) return
|
||||
this.post_liked = true;
|
||||
this.post_like_cnt++;
|
||||
}).catch(error => {
|
||||
console.log(error);
|
||||
this.errorMsg = error.toString();
|
||||
});
|
||||
})
|
||||
},
|
||||
|
||||
// Unlike the photo
|
||||
unlike() {
|
||||
this.$axios.delete("/users/" + this.user_id + "/photos/" + this.photo_id + "/likes/" + this.my_id).then(response => {
|
||||
this.$axios.delete("/users/" + this.user_id + "/photos/" + this.photo_id + "/likes/" + this.$currentSession()).then(response => {
|
||||
if (response == null) return
|
||||
this.post_liked = false;
|
||||
this.post_like_cnt--;
|
||||
}).catch(error => {
|
||||
console.log(error);
|
||||
this.errorMsg = error.toString();
|
||||
});
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.$axios.get("/users/" + this.user_id + "/photos/" + this.photo_id, {
|
||||
responseType: 'arraybuffer'
|
||||
}).then(response => {
|
||||
const img = document.createElement('img');
|
||||
img.src = URL.createObjectURL(new Blob([response.data]));
|
||||
img.classList.add("card-img-top");
|
||||
this.$refs.imageContainer.appendChild(img);
|
||||
});
|
||||
},
|
||||
}
|
||||
// Fetch the image from the server and display it
|
||||
this.$axios.get("/users/" + this.user_id + "/photos/" + this.photo_id, {
|
||||
responseType: 'arraybuffer'
|
||||
}).then(response => {
|
||||
// Create an image element and append it to the container
|
||||
const img = document.createElement('img');
|
||||
|
||||
// Set image source and css class
|
||||
img.src = URL.createObjectURL(new Blob([response.data]));
|
||||
img.classList.add("card-img-top");
|
||||
|
||||
// Append the image to the container and disable the spinner
|
||||
this.$refs.imageContainer.appendChild(img);
|
||||
this.imageReady = true;
|
||||
});
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card mb-5">
|
||||
<!--<img v-auth-img="imageSrc" class="card-img-top" alt="Chicago Skyscrapers"/>-->
|
||||
<div ref="imageContainer"></div>
|
||||
<div class="card mb-5">
|
||||
|
||||
<!-- Image container div -->
|
||||
<div ref="imageContainer">
|
||||
<div v-if="!imageReady" class="mt-3 mb-3">
|
||||
<LoadingSpinner :loading="!imageReady" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
|
||||
<!-- Username and date -->
|
||||
<div class="col-10">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{ name }}</h5>
|
||||
<h5 @click="visitUser" class="card-title d-inline-block" style="cursor: pointer">{{ name }}</h5>
|
||||
<p class="card-text">{{ new Date(Date.parse(date)) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comment and like buttons -->
|
||||
<div class="col-2">
|
||||
<div class="card-body d-flex justify-content-end" style="display: inline-flex"> <!-- not quite sure flex is the right property, but it works -->
|
||||
<a><h5><i class="card-title bi bi-chat-right pe-1"></i></h5></a>
|
||||
<h6 class="card-text d-flex align-items-end text-muted">{{ comments }}</h6>
|
||||
<a v-if="!post_liked" @click="like"><h5><i class="card-title bi bi-suit-heart ps-2 pe-1 like-icon"></i></h5></a>
|
||||
<a v-if="post_liked" @click="unlike"><h5><i class="card-title bi bi-heart-fill ps-2 pe-1 like-icon like-red"></i></h5></a>
|
||||
<div class="card-body d-flex justify-content-end" style="display: inline-flex">
|
||||
<a @click="showHideComments">
|
||||
<h5><i class="card-title bi bi-chat-right pe-1"></i></h5>
|
||||
</a>
|
||||
<h6 class="card-text d-flex align-items-end text-muted">{{ post_comments_cnt }}</h6>
|
||||
<a v-if="!post_liked" @click="like">
|
||||
<h5><i class="card-title bi bi-suit-heart ps-2 pe-1 like-icon"></i></h5>
|
||||
</a>
|
||||
<a v-if="post_liked" @click="unlike">
|
||||
<h5><i class="card-title bi bi-heart-fill ps-2 pe-1 like-icon like-red"></i></h5>
|
||||
</a>
|
||||
<h6 class="card-text d-flex align-items-end text-muted">{{ post_like_cnt }}</h6>
|
||||
<h5></h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--<ul class="list-group list-group-light list-group-small">
|
||||
<li class="list-group-item px-4">Cras justo odio</li>
|
||||
<li class="list-group-item px-4">Dapibus ac facilisis in</li>
|
||||
<li class="list-group-item px-4">Vestibulum at eros</li>
|
||||
</ul>-->
|
||||
<!-- Comments section -->
|
||||
<div v-if="comments_shown">
|
||||
<div v-for="item of comments_data" class="row" v-bind:key="item.comment_id">
|
||||
<div class="col-7 card-body border-top">
|
||||
<b>{{ item.name }}:</b> {{ item.comment }}
|
||||
</div>
|
||||
<div class="col-5 card-body border-top text-end text-secondary">
|
||||
{{ new Date(Date.parse(item.date)).toDateString() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Show more comments label -->
|
||||
<div v-if="!data_ended" class="col-12 card-body text-end pt-0 pb-1 px-0">
|
||||
<a @click="getComments" class="text-primary">Show more comments...</a>
|
||||
</div>
|
||||
|
||||
<!-- New comment form -->
|
||||
<div class="row">
|
||||
|
||||
<!-- Comment input -->
|
||||
<div class="col-10 card-body border-top text-end">
|
||||
<input v-model="commentMsg" type="text" class="form-control" placeholder="Commenta...">
|
||||
</div>
|
||||
|
||||
<!-- Comment publish button -->
|
||||
<div class="col-1 card-body border-top text-end ps-0 d-flex">
|
||||
<button style="width: 100%" type="button" class="btn btn-primary"
|
||||
@click="postComment">Go</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMsg v-if="errormsg" :msg="errormsg"></ErrorMsg>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.like-icon:hover {
|
||||
color: #ff0000;
|
||||
}
|
||||
|
||||
.like-red {
|
||||
color: #ff0000;
|
||||
}
|
||||
|
|
107
webui/src/components/ProfileCounters.vue
Normal file
107
webui/src/components/ProfileCounters.vue
Normal file
|
@ -0,0 +1,107 @@
|
|||
<script>
|
||||
export default {
|
||||
props: ["user_data"],
|
||||
|
||||
data: function () {
|
||||
return {
|
||||
modal_data: [],
|
||||
|
||||
data_type: "followers",
|
||||
|
||||
// Dynamic loading parameters
|
||||
data_ended: false,
|
||||
start_idx: 0,
|
||||
limit: 10,
|
||||
|
||||
loading: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
// Visit the profile of the user with the given id
|
||||
visit(user_id) {
|
||||
this.$router.push({ path: "/profile/" + user_id })
|
||||
},
|
||||
|
||||
// Reset the current data and fetch the first batch of users
|
||||
// then show the modal
|
||||
async loadData(type) {
|
||||
// Reset the parameters and the users array
|
||||
this.data_type = type
|
||||
this.start_idx = 0
|
||||
this.data_ended = false
|
||||
this.modal_data = []
|
||||
|
||||
// Fetch the first batch of users
|
||||
let status = await this.loadContent()
|
||||
|
||||
// Show the modal if the request was successful
|
||||
if (status) this.$refs["mymodal"].showModal()
|
||||
// If the request fails, the interceptor will show the error modal
|
||||
},
|
||||
|
||||
// Fetch users from the server
|
||||
async loadContent() {
|
||||
// Fetch followers / following from the server
|
||||
// uses /followers and /following endpoints
|
||||
let response = await this.$axios.get("/users/" + this.user_data["user_id"] + "/" + this.data_type + "?start_index=" + this.start_idx + "&limit=" + this.limit)
|
||||
if (response == null) return false // An error occurred. The interceptor will show a modal
|
||||
|
||||
// If the server returned less elements than requested,
|
||||
// it means that there are no more photos to load
|
||||
if (response.data.length == 0 || response.data.length < this.limit)
|
||||
this.data_ended = true
|
||||
|
||||
// Append the new photos to the array
|
||||
this.modal_data = this.modal_data.concat(response.data)
|
||||
return true
|
||||
},
|
||||
|
||||
// Load more users when the user scrolls to the bottom
|
||||
loadMore() {
|
||||
// Avoid sending a request if there are no more photos
|
||||
if (this.loading || this.data_ended) return
|
||||
|
||||
// Increase the start index and load more photos
|
||||
this.start_idx += this.limit
|
||||
this.loadContent()
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<!-- Modal to show the followers / following -->
|
||||
<Modal ref="mymodal" id="userModal" :title="data_type">
|
||||
<ul>
|
||||
<li v-for="item in modal_data" :key="item.user_id" class="mb-2" style="cursor: pointer"
|
||||
@click="visit(item.user_id)" data-bs-dismiss="modal">
|
||||
<h5>{{ item.name }}</h5>
|
||||
</li>
|
||||
<IntersectionObserver sentinal-name="load-more-users" @on-intersection-element="loadMore" />
|
||||
</ul>
|
||||
</Modal>
|
||||
|
||||
<!-- Profile counters -->
|
||||
<div class="row text-center mt-2 mb-3">
|
||||
|
||||
<!-- Photos counter -->
|
||||
<div class="col-4" style="border-right: 1px">
|
||||
<h3>{{ user_data["photos"] }}</h3>
|
||||
<h6>Photos</h6>
|
||||
</div>
|
||||
|
||||
<!-- Followers counter -->
|
||||
<div class="col-4" @click="loadData('followers')" style="cursor: pointer">
|
||||
<h3>{{ user_data["followers"] }}</h3>
|
||||
<h6>Followers</h6>
|
||||
</div>
|
||||
|
||||
<!-- Following counter -->
|
||||
<div class="col-4" @click="loadData('following')" style="cursor: pointer">
|
||||
<h3>{{ user_data["following"] }}</h3>
|
||||
<h6>Following</h6>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
|
@ -1,118 +1,216 @@
|
|||
<script>
|
||||
|
||||
export default {
|
||||
props: ["user_id", "name", "followed", "banned", "my_id", "show_new_post"],
|
||||
watch: {
|
||||
banned: function(new_val, old_val) {
|
||||
this.user_banned = new_val;
|
||||
props: ["user_id", "name", "followed", "banned", "show_new_post"],
|
||||
watch: {
|
||||
name: function (new_val, old_val) {
|
||||
this.username = new_val
|
||||
},
|
||||
followed: function(new_val, old_val) {
|
||||
this.user_followed = new_val;
|
||||
banned: function (new_val, old_val) {
|
||||
this.user_banned = new_val
|
||||
},
|
||||
followed: function (new_val, old_val) {
|
||||
this.user_followed = new_val
|
||||
},
|
||||
user_id: function (new_val, old_val) {
|
||||
this.myself = this.$currentSession() == new_val
|
||||
},
|
||||
},
|
||||
data: function() {
|
||||
return {
|
||||
errorMsg: "aaa",
|
||||
user_followed: this.followed,
|
||||
user_banned: this.banned,
|
||||
myself: this.my_id == this.user_id,
|
||||
data: function () {
|
||||
return {
|
||||
// User data
|
||||
username: this.name,
|
||||
user_followed: this.followed,
|
||||
user_banned: this.banned,
|
||||
|
||||
// Whether the user is the currently logged in user
|
||||
myself: this.$currentSession() == this.user_id,
|
||||
|
||||
// Whether to show the buttons to post a new photo and update the username
|
||||
show_post_form: false,
|
||||
show_username_form: false,
|
||||
|
||||
// The new username
|
||||
newUsername: "",
|
||||
|
||||
// The file to upload
|
||||
upload_file: null,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// Logout the user
|
||||
logout() {
|
||||
this.$root.logout()
|
||||
},
|
||||
|
||||
// Visit the user's profile
|
||||
visit() {
|
||||
this.$router.push({ path: "/profile/" + this.user_id });
|
||||
},
|
||||
follow() {
|
||||
this.$axios.put("/users/" + this.user_id + "/followers/" + this.my_id)
|
||||
.then(response => {
|
||||
this.user_followed = true
|
||||
this.$emit('updateInfo')
|
||||
})
|
||||
.catch(error => alert(error.toString()));
|
||||
},
|
||||
unfollow() {
|
||||
this.$axios.delete("/users/" + this.user_id + "/followers/" + this.my_id)
|
||||
.then(response => {
|
||||
this.user_followed = false
|
||||
this.$emit('updateInfo')
|
||||
})
|
||||
.catch(error => alert(error.toString()));
|
||||
},
|
||||
ban() {
|
||||
this.$axios.put("/users/" + this.my_id + "/bans/" + this.user_id)
|
||||
.then(response => {
|
||||
this.user_banned = true
|
||||
this.$emit('updateInfo')
|
||||
})
|
||||
.catch(error => alert(error.toString()));
|
||||
},
|
||||
unban() {
|
||||
this.$axios.delete("/users/" + this.my_id + "/bans/" + this.user_id)
|
||||
.then(response => {
|
||||
this.user_banned = false
|
||||
this.$emit('updateInfo')
|
||||
})
|
||||
.catch(error => alert(error.toString()));
|
||||
},
|
||||
|
||||
// Follow the user
|
||||
follow() {
|
||||
this.$axios.put("/users/" + this.user_id + "/followers/" + this.$currentSession())
|
||||
.then(response => {
|
||||
if (response == null) return // the interceptors returns null if something goes bad
|
||||
this.user_followed = true
|
||||
this.$emit('updateInfo')
|
||||
})
|
||||
},
|
||||
|
||||
// Unfollow the user
|
||||
unfollow() {
|
||||
this.$axios.delete("/users/" + this.user_id + "/followers/" + this.$currentSession())
|
||||
.then(response => {
|
||||
if (response == null) return
|
||||
this.user_followed = false
|
||||
this.$emit('updateInfo')
|
||||
})
|
||||
},
|
||||
|
||||
// Ban the user
|
||||
ban() {
|
||||
this.$axios.put("/users/" + this.$currentSession() + "/bans/" + this.user_id)
|
||||
.then(response => {
|
||||
if (response == null) return
|
||||
this.user_banned = true
|
||||
this.$emit('updateInfo')
|
||||
})
|
||||
},
|
||||
|
||||
// Unban the user
|
||||
unban() {
|
||||
this.$axios.delete("/users/" + this.$currentSession() + "/bans/" + this.user_id)
|
||||
.then(response => {
|
||||
if (response == null) return
|
||||
this.user_banned = false
|
||||
this.$emit('updateInfo')
|
||||
})
|
||||
},
|
||||
|
||||
// Prepare the file to upload
|
||||
load_file(e) {
|
||||
let files = e.target.files || e.dataTransfer.files;
|
||||
if (!files.length) return;
|
||||
this.upload_file = files[0];
|
||||
if (!files.length) return
|
||||
this.upload_file = files[0]
|
||||
},
|
||||
|
||||
// Upload the file
|
||||
submit_file() {
|
||||
this.$axios.post("/users/" + this.my_id + "/photos", this.upload_file)
|
||||
.then(response => {
|
||||
this.show_post_form = false
|
||||
this.$emit('updatePosts')
|
||||
})
|
||||
.catch(error => alert(error.toString()));
|
||||
this.$axios.post("/users/" + this.$currentSession() + "/photos", this.upload_file)
|
||||
.then(response => {
|
||||
if (response == null) return
|
||||
this.show_post_form = false
|
||||
this.$emit('updatePosts')
|
||||
})
|
||||
},
|
||||
|
||||
// Update the username
|
||||
updateUsername() {
|
||||
this.$axios.put("/users/" + this.$currentSession() + "/username", { name: this.newUsername })
|
||||
.then(response => {
|
||||
if (response == null) return
|
||||
this.show_username_form = false
|
||||
this.$emit('updateInfo')
|
||||
this.username = this.newUsername
|
||||
})
|
||||
},
|
||||
},
|
||||
created() {
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-5">
|
||||
<div class="card-body h-100 d-flex align-items-center">
|
||||
<a @click="visit">
|
||||
<h5 class="card-title mb-0 d-inline-block" style="cursor: pointer">{{ username }}</h5>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-10">
|
||||
<div class="card-body h-100 d-flex align-items-center">
|
||||
<a @click="visit"><h5 class="card-title mb-0">{{ name }}</h5></a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Whether to show one or two rows -->
|
||||
<div class="d-flex flex-column" v-bind:class="{
|
||||
'col-12': (myself && show_new_post),
|
||||
'col-sm-7': (myself && show_new_post),
|
||||
'col-7': !(myself && show_new_post),
|
||||
'align-items-end': !(myself && show_new_post),
|
||||
'align-items-sm-end': (myself && show_new_post),
|
||||
}">
|
||||
|
||||
<div class="col-2">
|
||||
<div class="card-body d-flex justify-content-end">
|
||||
<!-- Buttons -->
|
||||
<div class="card-body d-flex">
|
||||
<div v-if="!myself" class="d-flex">
|
||||
<button v-if="!user_banned" @click="ban" type="button" class="btn btn-outline-danger me-2">Ban</button>
|
||||
<button v-if="user_banned" @click="unban" type="button" class="btn btn-danger me-2">Banned</button>
|
||||
<button v-if="!user_followed" @click="follow" type="button" class="btn btn-primary">Follow</button>
|
||||
<button v-if="user_followed" @click="unfollow" type="button" class="btn btn-outline-primary">Following</button>
|
||||
<button v-if="!user_banned" @click="ban" type="button"
|
||||
class="btn btn-outline-danger me-2">Ban</button>
|
||||
<button v-if="user_banned" @click="unban" type="button"
|
||||
class="btn btn-danger me-2">Banned</button>
|
||||
<button v-if="!user_followed" @click="follow" type="button"
|
||||
class="btn btn-primary">Follow</button>
|
||||
<button v-if="user_followed" @click="unfollow" type="button"
|
||||
class="btn btn-outline-primary">Following</button>
|
||||
</div>
|
||||
|
||||
<!-- Users cannot follow or ban themselves -->
|
||||
<div v-if="(myself && !show_new_post)">
|
||||
<button disabled type="button" class="btn btn-secondary">Yourself</button>
|
||||
</div>
|
||||
<div v-if="(myself && show_new_post)" class="d-flex">
|
||||
<button v-if="!show_post_form" type="button" class="btn btn-primary" @click="show_post_form = true">Post</button>
|
||||
|
||||
<!-- Logout button -->
|
||||
<div v-if="(myself && show_new_post)" class="col">
|
||||
<button type="button" class="btn btn-outline-danger me-2" @click="logout">Logout</button>
|
||||
</div>
|
||||
|
||||
<div class="d-flex col justify-content-end flex-row">
|
||||
|
||||
<!-- Update username button -->
|
||||
<div v-if="(myself && show_new_post)" class="">
|
||||
<button v-if="!show_username_form" type="button" class="btn btn-outline-secondary me-2"
|
||||
@click="show_username_form = true">Username</button>
|
||||
</div>
|
||||
|
||||
<!-- Post a new photo button -->
|
||||
<div v-if="(myself && show_new_post)" class="">
|
||||
<button v-if="!show_post_form" type="button" class="btn btn-primary"
|
||||
@click="show_post_form = true">Post</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row" v-if="show_post_form">
|
||||
<div class="col-9">
|
||||
<div class="card-body h-100 d-flex align-items-center">
|
||||
<input @change="load_file" class="form-control form-control-lg" id="formFileLg" type="file" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-3">
|
||||
<div class="card-body d-flex justify-content-end">
|
||||
<button type="button" class="btn btn-primary btn-lg" @click="submit_file">Publish</button>
|
||||
<!-- File input -->
|
||||
<div class="row" v-if="show_post_form">
|
||||
<div class="col-9">
|
||||
<div class="card-body h-100 d-flex align-items-center">
|
||||
<input @change="load_file" class="form-control form-control-lg" id="formFileLg" type="file" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Publish button -->
|
||||
<div class="col-3">
|
||||
<div class="card-body d-flex justify-content-end">
|
||||
<button type="button" class="btn btn-primary btn-lg" @click="submit_file">Publish</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New username form -->
|
||||
<div class="row" v-if="show_username_form">
|
||||
|
||||
<!-- Username input -->
|
||||
<div class="col-10">
|
||||
<div class="card-body h-100 d-flex align-items-center">
|
||||
<input v-model="newUsername" class="form-control form-control-lg" id="formUsername"
|
||||
placeholder="Your new fantastic username! 😜" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Username update button -->
|
||||
<div class="col-2">
|
||||
<div class="card-body d-flex justify-content-end">
|
||||
<button type="button" class="btn btn-primary btn-lg" @click="updateUsername">Set</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -2,21 +2,33 @@ import {createApp, reactive} from 'vue'
|
|||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { axios, updateToken as axiosUpdate } from './services/axios.js';
|
||||
import getCurrentSession from './services/authentication';
|
||||
import ErrorMsg from './components/ErrorMsg.vue'
|
||||
import LoadingSpinner from './components/LoadingSpinner.vue'
|
||||
import PostCard from './components/PostCard.vue'
|
||||
import UserCard from './components/UserCard.vue'
|
||||
import ProfileCounters from './components/ProfileCounters.vue'
|
||||
import Modal from './components/Modal.vue'
|
||||
import IntersectionObserver from './components/IntersectionObserver.vue'
|
||||
import 'bootstrap-icons/font/bootstrap-icons.css'
|
||||
|
||||
import './assets/dashboard.css'
|
||||
import './assets/main.css'
|
||||
|
||||
// Create the Vue SPA
|
||||
const app = createApp(App)
|
||||
app.config.globalProperties.$axios = axios;
|
||||
app.config.globalProperties.$axiosUpdate = axiosUpdate;
|
||||
app.config.globalProperties.$currentSession = getCurrentSession;
|
||||
|
||||
// Register the components
|
||||
app.component("ErrorMsg", ErrorMsg);
|
||||
app.component("LoadingSpinner", LoadingSpinner);
|
||||
app.component("PostCard", PostCard);
|
||||
app.component("UserCard", UserCard);
|
||||
app.component("ProfileCounters", ProfileCounters);
|
||||
app.component("Modal", Modal);
|
||||
app.component("IntersectionObserver", IntersectionObserver);
|
||||
|
||||
app.use(router)
|
||||
app.mount('#app')
|
4
webui/src/services/authentication.js
Normal file
4
webui/src/services/authentication.js
Normal file
|
@ -0,0 +1,4 @@
|
|||
export default function getCurrentSession() {
|
||||
if (localStorage.getItem('token') == null) return sessionStorage.getItem('token');
|
||||
return localStorage.getItem('token');
|
||||
}
|
|
@ -1,8 +1,9 @@
|
|||
import axios from "axios";
|
||||
import getCurrentSession from "./authentication";
|
||||
|
||||
const instance = axios.create({
|
||||
baseURL: __API_URL__,
|
||||
timeout: 1000 * 5
|
||||
timeout: 1000 * 60
|
||||
});
|
||||
|
||||
//axios.interceptors.request.use(function (config) {
|
||||
|
@ -13,7 +14,7 @@ const instance = axios.create({
|
|||
//});
|
||||
|
||||
const updateToken = () => {
|
||||
instance.defaults.headers.common['Authorization'] = 'Bearer ' + sessionStorage.getItem('token');
|
||||
instance.defaults.headers.common['Authorization'] = 'Bearer ' + getCurrentSession();
|
||||
}
|
||||
|
||||
export {
|
||||
|
|
|
@ -1,56 +1,82 @@
|
|||
<script>
|
||||
export default {
|
||||
data: function() {
|
||||
data: function () {
|
||||
return {
|
||||
errormsg: null,
|
||||
// Whether the content is loading
|
||||
// to show the loading spinner
|
||||
loading: false,
|
||||
|
||||
// Stream data from the server
|
||||
stream_data: [],
|
||||
|
||||
// Whether the data has ended
|
||||
// to stop loading more data with the infinite scroll
|
||||
data_ended: false,
|
||||
|
||||
// Parameters to load data dynamically when scrolling
|
||||
start_idx: 0,
|
||||
limit: 1,
|
||||
my_id: sessionStorage.getItem("token"),
|
||||
|
||||
// Shows the retry button
|
||||
loadingError: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// Reload the whole page content
|
||||
// fetching it again from the server
|
||||
async refresh() {
|
||||
// Limits the number of posts to load based on the window height
|
||||
// to avoid loading too many posts at once
|
||||
// 450px is (a bit more) of the height of a single post
|
||||
this.limit = Math.round(window.innerHeight / 450);
|
||||
|
||||
// Reset the parameters and the data
|
||||
this.start_idx = 0;
|
||||
this.data_ended = false;
|
||||
this.stream_data = [];
|
||||
|
||||
// Fetch the first batch of posts
|
||||
this.loadContent();
|
||||
},
|
||||
|
||||
// Requests data from the server asynchronously
|
||||
async loadContent() {
|
||||
this.loading = true;
|
||||
this.errormsg = null;
|
||||
try {
|
||||
let response = await this.$axios.get("/stream?start_index=" + this.start_idx + "&limit=" + this.limit);
|
||||
if (response.data.length == 0) this.data_ended = true;
|
||||
else this.stream_data = this.stream_data.concat(response.data);
|
||||
this.loading = false;
|
||||
} catch (e) {
|
||||
if (e.response.status == 401) {
|
||||
this.$router.push({ path: "/login" });
|
||||
}
|
||||
this.errormsg = e.toString();
|
||||
|
||||
let response = await this.$axios.get("/stream?start_index=" + this.start_idx + "&limit=" + this.limit);
|
||||
|
||||
// Errors are handled by the interceptor, which shows a modal dialog to the user and returns a null response.
|
||||
if (response == null) {
|
||||
this.loading = false
|
||||
this.loadingError = true
|
||||
return
|
||||
}
|
||||
|
||||
// If the response is empty or shorter than the limit
|
||||
// then there is no more data to load
|
||||
if (response.data.length == 0 || response.data.length < this.limit) this.data_ended = true;
|
||||
this.stream_data = this.stream_data.concat(response.data);
|
||||
|
||||
// Finished loading, hide the spinner
|
||||
this.loading = false;
|
||||
},
|
||||
scroll () {
|
||||
window.onscroll = () => {
|
||||
let bottomOfWindow = Math.max(window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop) + window.innerHeight === document.documentElement.offsetHeight
|
||||
if (bottomOfWindow && !this.data_ended) {
|
||||
this.start_idx += this.limit;
|
||||
this.loadContent();
|
||||
}
|
||||
}
|
||||
|
||||
// Loads more data when the user scrolls down
|
||||
// (this is called by the IntersectionObserver component)
|
||||
loadMore() {
|
||||
// Avoid loading more content if the data has ended
|
||||
if (this.loading || this.data_ended) return
|
||||
|
||||
// Increase the start index and load more content
|
||||
this.start_idx += this.limit
|
||||
this.loadContent()
|
||||
},
|
||||
},
|
||||
|
||||
// Called when the view is mounted
|
||||
mounted() {
|
||||
// this way we are sure that we fill the first page
|
||||
// 450 is a bit more of the max height of a post
|
||||
// todo: may not work in 4k screens :/
|
||||
this.limit = Math.round(window.innerHeight / 450);
|
||||
this.scroll();
|
||||
this.loadContent();
|
||||
// Start loading the content
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -62,35 +88,40 @@ export default {
|
|||
<div class="col-xl-6 col-lg-9">
|
||||
<h3 class="card-title border-bottom mb-4 pb-2 text-center">Your daily WASAStream!</h3>
|
||||
|
||||
<ErrorMsg v-if="errormsg" :msg="errormsg"></ErrorMsg>
|
||||
|
||||
<!-- Show a message if there's no content to show -->
|
||||
<div v-if="(stream_data.length == 0)" class="alert alert-secondary text-center" role="alert">
|
||||
There's nothing here 😢
|
||||
<br />Why don't you start following somebody? 👻
|
||||
</div>
|
||||
|
||||
<div id="main-content" v-for="item of stream_data">
|
||||
<PostCard :user_id="item.user_id"
|
||||
:photo_id="item.photo_id"
|
||||
:name="item.name"
|
||||
:date="item.date"
|
||||
:comments="item.comments"
|
||||
:likes="item.likes"
|
||||
:liked="item.liked"
|
||||
:my_id="my_id" />
|
||||
<!-- The stream -->
|
||||
<div id="main-content" v-for="item of stream_data" v-bind:key="item.photo_id">
|
||||
<!-- PostCard for each photo -->
|
||||
<PostCard :user_id="item.user_id" :photo_id="item.photo_id" :name="item.name" :date="item.date"
|
||||
:comments="item.comments" :likes="item.likes" :liked="item.liked" />
|
||||
</div>
|
||||
|
||||
<div v-if="data_ended" class="alert alert-secondary text-center" role="alert">
|
||||
<!-- Show a message if there's no more content to show -->
|
||||
<div v-if="(data_ended && !(stream_data.length == 0))" class="alert alert-secondary text-center" role="alert">
|
||||
This is the end of your stream. Hooray! 👻
|
||||
</div>
|
||||
|
||||
<!-- The loading spinner -->
|
||||
<LoadingSpinner :loading="loading" /><br />
|
||||
|
||||
<div class="d-flex align-items-center flex-column">
|
||||
<!-- Retry button -->
|
||||
<button v-if="loadingError" @click="refresh" class="btn btn-secondary w-100 py-3">Retry</button>
|
||||
|
||||
<!-- Load more button -->
|
||||
<button v-if="(!data_ended && !loading)" @click="loadMore" class="btn btn-secondary py-1 mb-5"
|
||||
style="border-radius: 15px">Load more</button>
|
||||
|
||||
<!-- The IntersectionObserver for dynamic loading -->
|
||||
<IntersectionObserver sentinal-name="load-more-home" @on-intersection-element="loadMore" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
</template>
|
|
@ -1,105 +1,120 @@
|
|||
<script>
|
||||
import LoadingSpinner from '../components/LoadingSpinner.vue';
|
||||
|
||||
export default {
|
||||
data: function () {
|
||||
return {
|
||||
// The error message to display
|
||||
errormsg: null,
|
||||
|
||||
// Loading spinner state
|
||||
loading: false,
|
||||
some_data: null,
|
||||
|
||||
// Form inputs
|
||||
field_username: "",
|
||||
rememberLogin: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
// Send the login request to the server
|
||||
// if the login is successful, the token is saved
|
||||
// and the user is redirected to the previous page
|
||||
async login() {
|
||||
this.loading = true;
|
||||
this.errormsg = null;
|
||||
try {
|
||||
let response = await this.$axios.post("/session", {
|
||||
name: this.field_username,
|
||||
});
|
||||
//this.$router.push({ name: "home" });
|
||||
if (response.status == 201 || response.status == 200) {
|
||||
// Save the token in the session storage
|
||||
sessionStorage.setItem("token", response.data["user_id"]);
|
||||
|
||||
// Update the header
|
||||
this.$axiosUpdate();
|
||||
// Send the login request
|
||||
let response = await this.$axios.post("/session", {
|
||||
name: this.field_username,
|
||||
});
|
||||
|
||||
this.$router.push({ path: "/" });
|
||||
// Errors are handled by the interceptor, which shows a modal dialog to the user and returns a null response.
|
||||
if (response == null) {
|
||||
this.loading = false
|
||||
return
|
||||
}
|
||||
|
||||
// If the login is successful, save the token and redirect to the previous page
|
||||
if (response.status == 201 || response.status == 200) {
|
||||
// Save the token in the local storage if the user wants to be remembered
|
||||
if (this.rememberLogin) {
|
||||
localStorage.setItem("token", response.data["user_id"])
|
||||
sessionStorage.removeItem("token");
|
||||
}
|
||||
// Else save the token in the session storage
|
||||
else {
|
||||
this.errormsg = response.data["error"];
|
||||
sessionStorage.setItem("token", response.data["user_id"]);
|
||||
localStorage.removeItem("token");
|
||||
}
|
||||
// Tell the root view to enable the navbar
|
||||
this.$root.setLoggedIn();
|
||||
// Update the header
|
||||
this.$axiosUpdate();
|
||||
|
||||
// Go back to the previous page
|
||||
this.$router.go(-1);
|
||||
}
|
||||
catch (e) {
|
||||
this.errormsg = e.toString();
|
||||
else {
|
||||
// Login failed, show the error message
|
||||
this.errormsg = response.data["error"];
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
async refresh() {
|
||||
//this.loading = true;
|
||||
//this.errormsg = null;
|
||||
//try {
|
||||
// let response = await this.$axios.get("/");
|
||||
// this.some_data = response.data;
|
||||
//} catch (e) {
|
||||
// this.errormsg = e.toString();
|
||||
//}
|
||||
// Disable the loading spinner
|
||||
this.loading = false;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.refresh();
|
||||
},
|
||||
components: { LoadingSpinner }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vh-100 container py-5 h-100">
|
||||
<div class="row d-flex justify-content-center align-items-center h-100">
|
||||
<!--<div class="col-sm"><h2>* immagina un logo carino *</h2></div>-->
|
||||
<div class="col-12 col-md-8 col-lg-6 col-xl-5">
|
||||
<div class="card" style="border-radius: 1rem">
|
||||
<div class="card-body p-4">
|
||||
<!-- Login form centered in the page -->
|
||||
<div class="vh-100 container py-5 h-100">
|
||||
<div class="row d-flex justify-content-center align-items-center h-100">
|
||||
<!--<div class="col-sm"><h2>* immagina un logo carino *</h2></div>-->
|
||||
<div class="col-12 col-md-8 col-lg-6 col-xl-5">
|
||||
<div class="card" style="border-radius: 1rem">
|
||||
<div class="card-body p-4">
|
||||
|
||||
<h1 class="h2 pb-4 text-center">WASAPhoto</h1>
|
||||
<h1 class="h2 pb-4 text-center">WASAPhoto</h1>
|
||||
|
||||
<form>
|
||||
<!-- Email input -->
|
||||
<div class="form-floating mb-4">
|
||||
<input v-model="field_username" type="email" id="formUsername" class="form-control" placeholder="name@example.com"/>
|
||||
<label class="form-label" for="formUsername">Username</label>
|
||||
</div>
|
||||
|
||||
<!-- Password input -->
|
||||
<div class="form-floating mb-4">
|
||||
<input disabled type="password" id="formPassword" class="form-control" placeholder="gattina12"/>
|
||||
<label class="form-label" for="formPassword">Password</label>
|
||||
</div>
|
||||
|
||||
<!-- 2 column grid layout for inline styling -->
|
||||
<div class="row mb-4">
|
||||
<div class="col d-flex justify-content-center">
|
||||
<!-- Checkbox -->
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" value="" id="form2Example31" checked />
|
||||
<label class="form-check-label" for="form2Example31">Remember me</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit button -->
|
||||
<button style="width: 100%" type="button" class="btn btn-primary btn-block mb-4" @click="login">Sign in</button>
|
||||
<ErrorMsg v-if="errormsg" :msg="errormsg"></ErrorMsg>
|
||||
<LoadingSpinner :loading="loading" />
|
||||
</form>
|
||||
</div>
|
||||
<form>
|
||||
<!-- Email input -->
|
||||
<div class="form-floating mb-4">
|
||||
<input v-model="field_username" type="email" id="formUsername" class="form-control"
|
||||
placeholder="name@example.com" />
|
||||
<label class="form-label" for="formUsername">Username</label>
|
||||
</div>
|
||||
|
||||
<!-- Password input -->
|
||||
<div class="form-floating mb-4">
|
||||
<input style="display: none" disabled type="password" id="formPassword"
|
||||
class="form-control" placeholder="gattina12" />
|
||||
<label style="display: none" class="form-label" for="formPassword">Password</label>
|
||||
</div>
|
||||
|
||||
<!-- 2 column grid layout for inline styling -->
|
||||
<div class="row mb-4">
|
||||
<div class="col d-flex justify-content-center">
|
||||
<!-- Checkbox -->
|
||||
<div class="form-check">
|
||||
<input v-model="rememberLogin" class="form-check-input" type="checkbox" value=""
|
||||
id="form2Example31" />
|
||||
<label class="form-check-label" for="form2Example31">Remember me</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit button -->
|
||||
<button style="width: 100%" type="button" class="btn btn-primary btn-block mb-4"
|
||||
@click="login">Sign in</button>
|
||||
<ErrorMsg v-if="errormsg" :msg="errormsg"></ErrorMsg>
|
||||
<LoadingSpinner :loading="loading" />
|
||||
<i class="text-center text-secondary d-flex flex-column">share your special moments!</i>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
|
|
|
@ -1,72 +1,105 @@
|
|||
<script>
|
||||
import IntersectionObserver from '../components/IntersectionObserver.vue';
|
||||
|
||||
export default {
|
||||
data: function() {
|
||||
data: function () {
|
||||
return {
|
||||
errormsg: null,
|
||||
loading: false,
|
||||
// The profile to show
|
||||
requestedProfile: this.$route.params.user_id,
|
||||
|
||||
// Loading flags
|
||||
loading: true,
|
||||
loadingError: false,
|
||||
|
||||
// Profile data from the server
|
||||
user_data: [],
|
||||
|
||||
// Protos data from the server
|
||||
stream_data: [],
|
||||
|
||||
// Dynamic loading parameters
|
||||
data_ended: false,
|
||||
start_idx: 0,
|
||||
limit: 1,
|
||||
my_id: sessionStorage.getItem("token"),
|
||||
|
||||
user_data: [],
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
'$route.params.user_id': {
|
||||
handler: function (user_id) {
|
||||
if (user_id !== null && user_id !== undefined) this.refresh()
|
||||
},
|
||||
deep: true,
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async refresh() {
|
||||
this.getMainData();
|
||||
this.limit = Math.round(window.innerHeight / 450);
|
||||
this.start_idx = 0;
|
||||
this.data_ended = false;
|
||||
this.stream_data = [];
|
||||
this.loadContent();
|
||||
if (this.$route.params.user_id == "me") {
|
||||
// If the id is "me", show the current user's profile
|
||||
this.requestedProfile = this.$currentSession()
|
||||
}
|
||||
else {
|
||||
// Otherwise, show "id"'s profile
|
||||
this.requestedProfile = this.$route.params.user_id
|
||||
}
|
||||
|
||||
// Fetch profile info from the server
|
||||
this.getMainData()
|
||||
|
||||
// Limits the number of posts to load based on the window height
|
||||
// to avoid loading too many posts at once
|
||||
// 450px is (a bit more) of the height of a single post
|
||||
this.limit = Math.max(Math.round(window.innerHeight / 450), 1)
|
||||
|
||||
// Reset the parameters and the data
|
||||
this.start_idx = 0
|
||||
this.data_ended = false
|
||||
this.stream_data = []
|
||||
|
||||
// Fetch the first batch of posts
|
||||
this.loadContent()
|
||||
},
|
||||
|
||||
// Fetch profile info from the server
|
||||
async getMainData() {
|
||||
let response = await this.$axios.get("/users/" + this.requestedProfile);
|
||||
if (response == null) {
|
||||
// An error occurred, set the error flag
|
||||
this.loading = false
|
||||
this.loadingError = true
|
||||
return
|
||||
}
|
||||
this.user_data = response.data
|
||||
},
|
||||
|
||||
async getMainData() {
|
||||
try {
|
||||
let response = await this.$axios.get("/users/" + this.$route.params.user_id);
|
||||
this.user_data = response.data;
|
||||
} catch(e) {
|
||||
this.errormsg = e.toString();
|
||||
}
|
||||
},
|
||||
|
||||
// Fetch photos from the server
|
||||
async loadContent() {
|
||||
this.loading = true;
|
||||
this.errormsg = null;
|
||||
try {
|
||||
let response = await this.$axios.get("/users/" + this.$route.params.user_id + "/photos" + "?start_index=" + this.start_idx + "&limit=" + this.limit);
|
||||
if (response.data.length == 0) this.data_ended = true;
|
||||
else this.stream_data = this.stream_data.concat(response.data);
|
||||
this.loading = false;
|
||||
} catch (e) {
|
||||
if (e.response.status == 401) { // todo: move from here
|
||||
this.$router.push({ path: "/login" });
|
||||
}
|
||||
this.errormsg = e.toString();
|
||||
}
|
||||
let response = await this.$axios.get("/users/" + this.requestedProfile + "/photos" + "?start_index=" + this.start_idx + "&limit=" + this.limit)
|
||||
if (response == null) return // An error occurred. The interceptor will show a modal
|
||||
|
||||
// If the server returned less elements than requested,
|
||||
// it means that there are no more photos to load
|
||||
if (response.data.length == 0 || response.data.length < this.limit)
|
||||
this.data_ended = true
|
||||
|
||||
// Append the new photos to the array
|
||||
this.stream_data = this.stream_data.concat(response.data)
|
||||
|
||||
// Disable the loading spinner
|
||||
this.loading = false
|
||||
},
|
||||
scroll () {
|
||||
window.onscroll = () => {
|
||||
let bottomOfWindow = Math.max(window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop) + window.innerHeight === document.documentElement.offsetHeight
|
||||
if (bottomOfWindow && !this.data_ended) {
|
||||
this.start_idx += this.limit;
|
||||
this.loadContent();
|
||||
}
|
||||
}
|
||||
|
||||
// Load more photos when the user scrolls to the bottom of the page
|
||||
loadMore() {
|
||||
// Avoid sending a request if there are no more photos
|
||||
if (this.loading || this.data_ended) return
|
||||
|
||||
// Increase the start index and load more photos
|
||||
this.start_idx += this.limit
|
||||
this.loadContent()
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// this way we are sure that we fill the first page
|
||||
// 450 is a bit more of the max height of a post
|
||||
// todo: may not work in 4k screens :/
|
||||
this.getMainData();
|
||||
this.limit = Math.round(window.innerHeight / 450);
|
||||
this.scroll();
|
||||
this.loadContent();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -77,54 +110,42 @@ export default {
|
|||
<div class="row justify-content-md-center">
|
||||
<div class="col-xl-6 col-lg-9">
|
||||
|
||||
<ErrorMsg v-if="errormsg" :msg="errormsg"></ErrorMsg>
|
||||
<!-- User card for profile info -->
|
||||
<UserCard :user_id="requestedProfile" :name="user_data['name']" :followed="user_data['followed']"
|
||||
:banned="user_data['banned']" :my_id="this.$currentSession" :show_new_post="true"
|
||||
@updateInfo="getMainData" @updatePosts="refresh" />
|
||||
|
||||
<UserCard :user_id = "$route.params.user_id"
|
||||
:name = "user_data['name']"
|
||||
:followed = "user_data['followed']"
|
||||
:banned = "user_data['banned']"
|
||||
:my_id = "my_id"
|
||||
:show_new_post = "true"
|
||||
@updateInfo = "getMainData"
|
||||
@updatePosts = "refresh" />
|
||||
|
||||
<div class="row text-center mt-2 mb-3">
|
||||
<div class="col-4" style="border-right: 1px">
|
||||
<h3>{{ user_data["photos"] }}</h3>
|
||||
<h6>Photos</h6>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<h3>{{ user_data["followers"] }}</h3>
|
||||
<h6>Followers</h6>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<h3>{{ user_data["following"] }}</h3>
|
||||
<h6>Following</h6>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Photos, followers and following counters -->
|
||||
<ProfileCounters :user_data="user_data" />
|
||||
|
||||
<div id="main-content" v-for="item of stream_data">
|
||||
<PostCard :user_id = "$route.params.user_id"
|
||||
:photo_id = "item.photo_id"
|
||||
:name = "user_data['name']"
|
||||
:date = "item.date"
|
||||
:comments = "item.comments"
|
||||
:likes = "item.likes"
|
||||
:liked = "item.liked"
|
||||
:my_id = "my_id" />
|
||||
<!-- Photos -->
|
||||
<div id="main-content" v-for="item of stream_data" v-bind:key="item.photo_id">
|
||||
<!-- PostCard for the photo -->
|
||||
<PostCard :user_id="requestedProfile" :photo_id="item.photo_id" :name="user_data['name']"
|
||||
:date="item.date" :comments="item.comments" :likes="item.likes" :liked="item.liked" />
|
||||
</div>
|
||||
|
||||
<!-- Message when the end is reached -->
|
||||
<div v-if="data_ended" class="alert alert-secondary text-center" role="alert">
|
||||
Hai visualizzato tutti i post. Hooray! 👻
|
||||
You reached the end. Hooray! 👻
|
||||
</div>
|
||||
|
||||
<LoadingSpinner :loading="loading" /><br />
|
||||
<!-- The loading spinner -->
|
||||
<LoadingSpinner :loading="loading" />
|
||||
|
||||
<div class="d-flex align-items-center flex-column">
|
||||
<!-- Refresh button -->
|
||||
<button v-if="loadingError" @click="refresh" class="btn btn-secondary w-100 py-3">Retry</button>
|
||||
|
||||
<!-- Load more button -->
|
||||
<button v-if="(!data_ended && !loading)" @click="loadMore" class="btn btn-secondary py-1 mb-5"
|
||||
style="border-radius: 15px">Load more</button>
|
||||
|
||||
<!-- The IntersectionObserver for dynamic loading -->
|
||||
<IntersectionObserver sentinal-name="load-more-profile" @on-intersection-element="loadMore" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
</template>
|
|
@ -1,62 +1,79 @@
|
|||
<script>
|
||||
// import getCurrentSession from '../services/authentication'; todo: can be removed
|
||||
export default {
|
||||
data: function() {
|
||||
data: function () {
|
||||
return {
|
||||
// The error message to display
|
||||
errormsg: null,
|
||||
|
||||
loading: false,
|
||||
stream_data: [],
|
||||
data_ended: false,
|
||||
start_idx: 0,
|
||||
|
||||
// Search results
|
||||
streamData: [],
|
||||
|
||||
// Dynamic loading
|
||||
dataEnded: false,
|
||||
startIdx: 0,
|
||||
limit: 1,
|
||||
field_username: "",
|
||||
my_id: sessionStorage.getItem("token"),
|
||||
|
||||
// Search input
|
||||
fieldUsername: "",
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async refresh() {
|
||||
// Reset the results and fetch the new requested ones
|
||||
async query() {
|
||||
// Set the limit to the number of cards that can fit in the window
|
||||
this.limit = Math.round(window.innerHeight / 72);
|
||||
this.start_idx = 0;
|
||||
this.data_ended = false;
|
||||
this.stream_data = [];
|
||||
|
||||
// Reset the parameters and the data
|
||||
this.startIdx = 0;
|
||||
this.dataEnded = false;
|
||||
this.streamData = [];
|
||||
|
||||
// Fetch the first batch of results
|
||||
this.loadContent();
|
||||
},
|
||||
|
||||
// Fetch the search results from the server
|
||||
async loadContent() {
|
||||
this.loading = true;
|
||||
this.errormsg = null;
|
||||
if (this.field_username == "") {
|
||||
|
||||
// Check if the username is empty
|
||||
// and show an error message
|
||||
if (this.fieldUsername == "") {
|
||||
this.errormsg = "Please enter a username";
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let response = await this.$axios.get("/users?query=" + this.field_username + "&start_index=" + this.start_idx + "&limit=" + this.limit);
|
||||
if (response.data.length == 0) this.data_ended = true;
|
||||
else this.stream_data = this.stream_data.concat(response.data);
|
||||
this.loading = false;
|
||||
} catch (e) {
|
||||
this.errormsg = e.toString();
|
||||
if (e.response.status == 401) {
|
||||
this.$router.push({ path: "/login" });
|
||||
}
|
||||
|
||||
// Fetch the results from the server
|
||||
let response = await this.$axios.get("/users?query=" + this.fieldUsername + "&start_index=" + this.startIdx + "&limit=" + this.limit);
|
||||
|
||||
// Errors are handled by the interceptor, which shows a modal dialog to the user and returns a null response.
|
||||
if (response == null) {
|
||||
this.loading = false
|
||||
return
|
||||
}
|
||||
|
||||
// If there are no more results, set the dataEnded flag
|
||||
if (response.data.length == 0) this.dataEnded = true;
|
||||
|
||||
// Otherwise, append the new results to the array
|
||||
else this.streamData = this.streamData.concat(response.data);
|
||||
|
||||
// Hide the loading spinner
|
||||
this.loading = false;
|
||||
},
|
||||
scroll () {
|
||||
window.onscroll = () => {
|
||||
let bottomOfWindow = Math.max(window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop) + window.innerHeight === document.documentElement.offsetHeight
|
||||
if (bottomOfWindow && !this.data_ended) {
|
||||
this.start_idx += this.limit;
|
||||
this.loadContent();
|
||||
}
|
||||
}
|
||||
|
||||
// Load a new batch of results when the user scrolls to the bottom of the page
|
||||
loadMore() {
|
||||
if (this.loading || this.dataEnded) return
|
||||
this.startIdx += this.limit
|
||||
this.loadContent()
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// this way we are sure that we fill the first page
|
||||
// 72 is a bit more of the max height of a card
|
||||
// todo: may not work in 4k screens :/
|
||||
this.limit = Math.round(window.innerHeight / 72);
|
||||
this.scroll();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -68,23 +85,28 @@ export default {
|
|||
|
||||
<h3 class="card-title border-bottom mb-4 pb-2 text-center">WASASearch</h3>
|
||||
|
||||
<!-- Error message -->
|
||||
<ErrorMsg v-if="errormsg" :msg="errormsg"></ErrorMsg>
|
||||
|
||||
<div class="form-floating mb-4">
|
||||
<input v-model="field_username" @input="refresh" id="formUsername" class="form-control" placeholder="name@example.com"/>
|
||||
<label class="form-label" for="formUsername">Search by username</label>
|
||||
</div>
|
||||
|
||||
<div id="main-content" v-for="item of stream_data">
|
||||
<UserCard
|
||||
:user_id="item.user_id"
|
||||
:name="item.name"
|
||||
:followed="item.followed"
|
||||
:banned="item.banned"
|
||||
:my_id="my_id" />
|
||||
<!-- Search form -->
|
||||
<div class="form-floating mb-4">
|
||||
<input v-model="fieldUsername" @input="query" id="formUsername" class="form-control"
|
||||
placeholder="name@example.com" />
|
||||
<label class="form-label" for="formUsername">Search by username</label>
|
||||
</div>
|
||||
|
||||
<!-- Search results -->
|
||||
<div id="main-content" v-for="item of streamData" v-bind:key="item.user_id">
|
||||
<!-- User card (search result entry) -->
|
||||
<UserCard :user_id="item.user_id" :name="item.name" :followed="item.followed"
|
||||
:banned="item.banned" />
|
||||
</div>
|
||||
|
||||
<!-- Loading spinner -->
|
||||
<LoadingSpinner :loading="loading" /><br />
|
||||
|
||||
<!-- The IntersectionObserver for dynamic loading -->
|
||||
<IntersectionObserver sentinal-name="load-more-search" @on-intersection-element="loadMore" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -93,4 +115,5 @@ export default {
|
|||
</template>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
|
|
|
@ -13,14 +13,18 @@ export default defineConfig(({command, mode, ssrBuild}) => {
|
|||
}
|
||||
},
|
||||
};
|
||||
if (command === 'serve') {
|
||||
if (command === 'serve' && mode !== 'developement-external') {
|
||||
ret.define = {
|
||||
"__API_URL__": JSON.stringify("http://localhost:3000"),
|
||||
};
|
||||
} else {
|
||||
} else if (mode === 'embedded') {
|
||||
ret.define = {
|
||||
"__API_URL__": JSON.stringify("/"),
|
||||
};
|
||||
} else {
|
||||
ret.define = {
|
||||
"__API_URL__": JSON.stringify("<your API URL>"),
|
||||
};
|
||||
}
|
||||
return ret;
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue